干货 | 移动端使用OpenGL转场特效的音视频合成应用

2023-11-11 20:32:33 浏览数 (1)

作者简介

jzg,携程资深前端开发工程师,专注Android开发;

zx,携程高级前端开发工程师,专注iOS开发;

zcc,携程资深前端开发工程师,专注iOS开发。

前言

近年来短视频的火爆,让内容创作类的APP获得了巨大的流量。用户通过这类工具编辑自己的短视频,添加各式各样的炫酷特效,从而呈现出更加丰富多彩的视频内容。本文将会介绍如何使用移动端原生API,将图片添加转场特效并且最终合成为视频的基本流程。

一、音视频基础知识

我们经常会和视频打交道,最常见的就是MP4格式的视频。这样的视频其实一般是由音频和视频组成的音视频容器。下面先会介绍音视频相关概念,为音视频技术的应用作一个铺垫,希望能对音视频频开发者提供一些帮助。

1.1 视频的基础知识

1.1.1 视频帧

视频中的一个基本概念就是帧,帧用来表示一个画面。视频的连续画面就是由一个个连续的视频帧组成。

1.1.2 帧率

帧率,FPS,全称Frames Per Second。指每秒传输的帧数,或者每秒显示的帧数。一般来说,帧率影响画面流畅度,且成正比:帧率越大,画面越流畅;帧率越小,画面越有跳动感。一个较权威的说法:当视频帧率不低于24FPS时,人眼才会觉得视频是连贯的,称为“视觉暂留”现象。16FPS可以达到一定的满意程度,但效果略差。因此,才有说法:尽管帧率越高越流畅,但在很多实际应用场景中24FPS就可以了(电影标准24FPS,电视标准PAL制25FPS)。

1.1.3 分辨率

分辨率,Resolution,也常被俗称为图像的尺寸或者图像的大小。指一帧图像包含的像素的多少,常见有1280x720(720P),1920X1080(1080P)等规格。分辨率影响图像大小,且与之成正比:分辨率越高,图像越大;反之,图像越小。

1.1.4 码率

码率,BPS,全称Bits Per Second。指每秒传送的数据位数,常见单位KBPS(千位每秒)和MBPS(兆位每秒)。码率是更广泛的(视频)质量指标:更高的分辨率,更高的帧率和更低的压缩率,都会导致码率增加。

1.1.5 色彩空间

通常说的色彩空间有两种:

RGB:RGB的颜色模式应该是我们最熟悉的一种,在现在的电子设备中应用广泛。通过R、G、B三种基础色,可以混合出所有的颜色。

YUV:YUV是一种亮度与色度分离的色彩格式,三个字母的意义分别为:

Y:亮度,就是灰度值。除了表示亮度信号外,还含有较多的绿色通道量。单纯的Y分量可以显示出完整的黑白图像。

U:蓝色通道与亮度的差值。

V:红色通道与亮度的差值。

其中,U、V分量分别表示蓝(blue)、红(red)分量信号,只含有色度信息,所以YUV也称为YCbCr,其中,Cb、Cr的含义等同于U、V,C可以理解为component或者color。

RGB和YUV的换算

YUV与RGB相互转换的公式如下(RGB取值范围均为0-255):

代码语言:javascript复制
Y = 0.299R   0.587G   0.114BU = -0.147R - 0.289G   0.436BV = 0.615R - 0.515G - 0.100B R = Y   1.14VG = Y - 0.39U - 0.58VB = Y   2.03U

1.2 音频的基础知识

音频数据的承载方式最常用的是脉冲编码调制,即PCM。

1.2.1 采样率和采样位数

采样率是将声音进行数字化的采样频率,采样位数与记录声波振幅有关,位数越高,记录的就越准确。

1.2.2 声道数

声道数,是指支持能不同发声(注意是不同声音)的音响的个数。

1.2.3 码率

码率,是指一个数据流中每秒钟能通过的信息量,单位bps(bit per second)。

码率 = 采样率 * 采样位数 * 声道数

上面介绍的音视频的数据还需要进行压缩编码,因为音视频的数据量都非常大,按照原始数据保存会非常的耗费空间,而且想要传输这样庞大的数据也很不方便。其实音视频的原始数据中包含大量的重复数据,特别是视频,一帧一帧的画面中包含大量的相似的内容。所以需要对音视频数据进行编码,以便于减小占用的空间,提高传输的效率。

1.3 视频编码

通俗地理解,例如一个视频中,前一秒画面跟当前的画面内容相似度很高,那么这两秒的数据是不是可以不用全部保存,只保留一个完整的画面,下一个画面看有哪些地方有变化了记录下来,拿视频去播放的时候就按这个完整的画面和其他有变化的地方把其他画面也恢复出来。记录画面不同然后保存下来这个过程就是数据编码,根据不同的地方恢复画面的过程就是数据解码。

代码语言:javascript复制
一般常见的视频编码格式有H26x系列和MPEG系列。
H26x(1/2/3/4/5)系列由ITU(International Telecommunication Union)国际电传视讯联盟主导。
MPEG(1/2/3/4)系列由MPEG(Moving Picture Experts Group, ISO旗下的组织)主导。

H264是新一代的编码标准,以高压缩高质量和支持多种网络的流媒体传输著称。iOS 8.0及以上苹果开放了VideoToolbox框架来实现H264硬编码,开发者可以利用VideoToolbox框架很方便地实现视频的硬编码。

H264编码的优势:

  • 低码率
  • 高质量的图像
  • 容错能力强
  • 网络适应性强

H264最大的优势,具有很高的数据压缩比率,在同等图像质量下,H264的压缩比是MPEG-2的2倍以上,MPEG-4的1.5~2倍。举例: 原始文件的大小如果为88GB,采用MPEG-2压缩标准压缩后变成3.5GB,压缩比为25∶1,而采用H.264压缩标准压缩后变为879MB,从88GB到879MB,H.264的压缩比达到惊人的102∶1。

1.4 音频编码

和视频编码一样,音频也有许多的编码格式,如:WAV、MP3、WMA、APE、FLAC等等。

AAC

  • AAC是目前比较热门的有损压缩编码技术,并且衍生了LC-AAC,HE-AAC,HE-AAC v2 三种主要编码格式
  • 特点:在小于128Kbit/s的码率下表现优异,并且多用于视频中的音频编码
  • 使用场合:128Kbit/s以下的音频编码,多用于视频中音频轨的编码

WAV

  • 在PCM数据格式的前面加上44字节,描述PCM的采样率、声道数、数据格式等信息,不会压缩
  • 特点:音质好,大量软件支持
  • 使用场合:多媒体开发的中间文件、保存音乐和音效素材

MP3

  • 使用LAME编码
  • 特点:音质在128kbit/s以上表现不错,压缩比较高,大量软件硬件都支持,兼容性好
  • 使用场合:高比特率(传输效率 bps, 这里的b是位,不是比特)对兼容性有要求的音乐欣赏

OGG

  • 特点:可以用比MP3更小的码率实现比MP3更好的音质,高中低码率下均有良好的表现
  • 不足:兼容性不够好,流媒体特性不支持
  • 适合场景:语音聊天的音频消息场景

APE

  • 无损压缩

FLAC

  • 专门针对PCM音频的特点设计的压缩方式,而且可以使用播放器直接播放FLAC压缩的文件
  • 免费,支持大多数操作系统

二、使用OpenGL的底层转场特效和原生平台硬编码进行图片、音乐、转场合成视频需要哪些 API

2.1 Android端和使用流程及相关API介绍

如果想要给图片添加转场特效并且合成为视频,需要使用OpenGL对图片进行渲染,搭配自定义的转场着色器,先让图片"动起来"。然后使用MediaCodec将画面内容进行编码,然后使用MediaMuxer将编码后的内容打包成一个音视频容器文件。

2.1.1 Mediacodec

MediaCodec是从API16后引入的处理音视频编解码的类,它可以直接访问Android底层的多媒体编解码器,通常与MediaExtractor,MediaSync, MediaMuxer,MediaCrypto,MediaDrm,Image,Surface,以及AudioTrack一起使用。

下面是官网提供的MediaCodec工作的流程图:

我们可以看到左边是input,右边是output。这里要分两种情况来讨论:

1)利用MediaCodec进行解码的时候,输入input是待解码的buffer数据,输出output是解码好的buffer数据。

2)利用MediaCodec进行编码的时候,输入input是一个待编码的数据,输出output是编码好的buffer数据。

代码语言:javascript复制
    val width = 720
    val height = 1280
    val bitrate = 5000
    val encodeType = "video/avc"
    //配置用于编码的MediaCodec
    val mCodec = MediaCodec.createEncoderByType(encodeType)
    val outputFormat = MediaFormat.createVideoFormat(encodeType, width, height)
    outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
    outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, DEFAULT_ENCODE_FRAME_RATE)
    outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
    outputFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        outputFormat.setInteger(
            MediaFormat.KEY_BITRATE_MODE,
            MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CQ
        )
    }
    codec.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
    //这一步很关键,这一步得到的surface后面将会用到
    val mSurface = codec.createInputSurface()
    mCodec.start()
    val mOutputBuffers = mCodec.outputBuffers
    val mInputBuffers = mCodec.inputBuffers

以上是MediaCodec的作为编码器的基本配置,其中MediaCodec.createInputSurface()这个方法可以为我们创建一个用于向MediaCodec进行输入的surface。这样通过MediaCodec就能获取到编码后的数据了。用这样的方式编码我们不需要向MedaiCodec输入待编码的数据,MediaCodec会自动将输入到surface的数据进行编码。

2.1.2 EGL环境

OpenGL是一组用来操作GPU的API,但它并不能将绘制的内容渲染到设备的窗口上,这里需要一个中间层,用来作为OpenGL和设备窗口之间的桥梁,并且最好是跨平台的,这就是EGL,是由Khronos Group提供的一组平台无关的API。

OpenGL绘制的内容一般都是呈现在GLSurfaceView中的(GLSurfaceView的surface),如果我们需要将内容编码成视频,需要将绘制的内容渲染到MediaCodec提供的Surface中,然后获取MediaCodec输出的编码后的数据,封装到指定的音视频文件中。

创建EGL环境的主要步骤如下:

代码语言:javascript复制
//1,创建 EGLDisplay
val mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
// 2,初始化 EGLDisplay
val version = IntArray(2)
EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)
// 3,初始化EGLConfig,EGLContext上下文
val config :EGLConfig? = null
if (mEGLContext === EGL14.EGL_NO_CONTEXT) {
    var renderableType = EGL14.EGL_OPENGL_ES2_BIT
        val attrList = intArrayOf(
            EGL14.EGL_RED_SIZE, 8,
            EGL14.EGL_GREEN_SIZE, 8,
            EGL14.EGL_BLUE_SIZE, 8,
            EGL14.EGL_ALPHA_SIZE, 8,
            EGL14.EGL_RENDERABLE_TYPE, renderableType,
            EGL14.EGL_NONE, 0,
            EGL14.EGL_NONE
        )
        //配置Android指定的标记
        if (flags and FLAG_RECORDABLE != 0) {
            attrList[attrList.size - 3] = EGL_RECORDABLE_ANDROID
            attrList[attrList.size - 2] = 1
        }
        val configs = arrayOfNulls<EGLConfig>(1)
        val numConfigs = IntArray(1)

        //获取可用的EGL配置列表
        if (!EGL14.eglChooseConfig(mEGLDisplay, attrList, 0,
                configs, 0, configs.size,
                numConfigs, 0)) {
            configs[0]
        }
    val attr2List = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
    val context = EGL14.eglCreateContext(
        mEGLDisplay, config, sharedContext,
        attr2List, 0
    )
    mEGLConfig = config
    mEGLContext = context
}
//这里还需要创建一个EGL用于输出的surface,这里的参数就可以传入上一小节介绍的利用MeddiaCodec创建的Surface
 fun createWindowSurface(surface: Any): EGLSurface {
        val surfaceAttr = intArrayOf(EGL14.EGL_NONE)

        val eglSurface = EGL14.eglCreateWindowSurface(
                                        mEGLDisplay, mEGLConfig, surface,
                                        surfaceAttr, 0)

        if (eglSurface == null) {
            throw RuntimeException("Surface was null")
        }

        return eglSurface
    }

配置EGL环境后,还要一个surface作为输出,这里就是要利用MediaCodec创建的surface作为输出,即EGL的输出作为MediaCodec的输入。

2.1.3 MediaMuxer

MediaMuxer是Android平台的音视频合成工具,上面我们介绍了MediaCodec可以编码数据,EGL环境可以让OpenGL程序将绘制的内容渲染到MediaCodec中,MediaCodec将这些数据编码,最后这些编码后的数据需要使用MediaMuxer写入到指定的文件中。

MediaMuxer基本使用:

代码语言:javascript复制
//创建一个MediaMuxer,需要指定输出保存的路径,和输出保存的格式。
val mediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
//根据MediaFormat添加媒体轨道
mediaMuxer.addTrack(MediaFormat(...))
//将输入的数据,根据指定的轨道保存到指定的文件路径中。
muxer.writeSampleData(currentTrackIndex, inputBuffer, bufferInfo);
//结合上面的所说的使用MediaCodec获取到已编码的数据
//当前帧的信息
var mBufferInfo = MediaCodec.BufferInfo()
//编码输出缓冲区
var mOutputBuffers: Array<ByteBuffer>? = null
//获取到编码输出的索引,写到指定的保存路径
val index = mCodec.dequeueOutputBuffer(mBufferInfo, 1000)
muxer.writeSampleData(currentTrackIndex,mOutputBuffers[index],mBufferInfo)

2.1.4 MediaExtractor

MediaExtractor是Android平台的多媒体提取器,能够根据视频轨道或者音频轨道去提取对应的数据。在进行视频编辑时,可以利用MediaExtractor来提取指定的音频信息,封装到目标音视频文件中。

代码语言:javascript复制
 //根据指定文件路径创建MediaExtractor
 val mediaExtractor = MediaExtractor(...)
 //为MediaExtractor选择好对应的媒体轨道
 mediaExtractor.selectTrack(...)
 //读取一帧的数据
 val inputBuffer = ByteBuffer.allocate(...)
 mediaExtractor.readSampleData(inputBuffer, 0)
 //进入下一帧
 mediaExtractor.advance()
 //MediaExtractor读取到的音频数据可以使用MediaMuxer的writeSampleData方法写入到指定的文件中

以上就是利用Android平台的硬编码相关API,将OpenGL渲染到画面编码成视频的基本流程介绍。

三、iOS端合成流程及相关API使用

由于AVFoundation原生框架对于图层特效处理能力有限,无法直接生成和写入多张图片之间切换的转场效果,所以需要自行对图片和音乐按照时间线,去实现音视频数据从解码到转场特效应用,以及最终写入文件的整个流程。

那么在多张图片合成视频的过程中,核心的部分就是如何处理多张图片之间的转场效果。这个时候我们需要配合OpenGL底层的特效能力,自定义滤镜将即将要切换的2张图片通过片元着色器生成新的纹理。本质就是在这两个纹理对象上去实现纹理和纹理之间的切换,通过Mix函数混合两个纹理图像,使用time在[0,1]之间不停变化来控制第二个图片纹理混合的强弱变化从而实现渐变效果。接下来开始介绍合成的流程和具体API的使用。

3.1 音视频基础API

在合成的过程中,我们使用到了AVAssetWriter这个类。AVAssetWriter可以将多媒体数据从多个源进行编码(比如接下来的多张图片和一个BGM进行合成)并写入指定文件格式的容器中,比如我们熟知的MPEG-4文件。

3.1.1 AVAssetWriter 与AVAssetWriterInput

AVAssetWriter通常由一个或多个AVAssetWriterInput对象构成,将AVAssetWriterInput配置为可以处理指定的多媒体类型,比如音频或视频,用于添加将包含要写入容器的多媒体数据的CMSampleBufferRef对象。同时因为asset writer可以从多个数据源写入容器,因此必须要为写入文件的每个track(即音频轨道、视频轨道)创建一个对应的AVAssetWriterInput对象。

AVAssetWriterInput可以设置视频的主要参数如输出码率,帧率,最大帧间隔,编码方式,输出分辨率以及填充模式等。也可以设置音频的主要参数如采样率,声道,编码方式,输出码率等。

3.1.2 CMSampleBufferRef 与AVAssetWriterInputPixelBufferAdaptor

CMSampleBuffer是一个基础类,用于处理音视频管道传输中的通用数据。CMSampleBuffer中包含零个或多个某一类型如音频或者视频的采样数据。可以封装音频采集后、编码后、解码后的数据(PCM数据、AAC数据)以及视频编码后的数据(H.264数据)。而CMSampleBufferRef是对CMSampleBuffer的一种引用。在提取音频的时候,像如下的使用方式同步复制输出的下一个示例缓冲区。

代码语言:javascript复制
CMSampleBufferRef sampleBuffer = [assetReaderAudioOutput copyNextSampleBuffer];

每个AVAssetWriterInput期望以CMSampleBufferRef对象形式接收数据,如果在处理视频样本的数据时,便要将CVPixelBufferRef类型对象(像素缓冲样本数据)添加到asset writer input,这个时候就需要使用AVAssetWriterInputPixelBufferAdaptor 这个专门的适配器类。这个类在附加被包装为CVPixelBufferRef对象的视频样本时提供最佳性能。

AVAssetWriterInputPixelBufferAdaptor它是一个输入的像素缓冲适配器,作为assetWriter的视频输入源,用于把缓冲池中的像素打包追加到视频样本上。在写入文件的时候,需要将CMSampleBufferRef转成CVPixelBuffer,而这个转换是在CVPixelBufferPool中完成的。AVAssetWriterInputPixelBufferAdaptor的实例提供了一个CVPixelBufferPool,可用于分配像素缓冲区来写入输出数据。使用它提供的像素缓冲池进行缓冲区分配通常比使用额外创建的缓冲区更加高效。

代码语言:javascript复制
CVPixelBufferRef pixelBuffer = NULL;
    CVPixelBufferPoolCreatePixelBuffer(NULL, self.inputPixelBufferAdptor.pixelBufferPool,&pixelBuffer);

每个AVAssetWriterInputPixelBufferAdaptor都包含一个assetWriterInput,用于接收缓冲区中的数据,并且AVAssetWriterInput有一个很重要的属性readyForMoreMediaData,来标识现在缓冲区中的数据是否已经处理完成。通过判断这个属性,我们可以向AVAssetWriterInputPixelBufferAdaptor中添加数据(appendPixelBuffer:)以进行处理。

代码语言:javascript复制
if(self.inputPixelBufferAdptor.assetWriterInput.isReadyForMoreMediaData) {
    BOOL success = [self.inputPixelBufferAdptor appendPixelBuffer:newPixelBuffer withPresentationTime:self.currentSampleTime];    

    if (success) {
        NSLog(@"append buffer success");
    }
}

3.1.3 设置输入输出参数,以及多媒体数据的采样

第一步:创建AVAssetWriter对象传入生成视频的路径和格式

代码语言:javascript复制
AVAssetWriter *assetWriter = [[AVAssetWriter alloc] initWithURL:[NSURL fileURLWithPath:outFilePath] fileType:AVFileTypeMPEG4 error:&outError];

第二步:设置输出视频相关信息,如大小,编码格式H264,以及创建视频的输入类videoWriterInput,以便后续给assetReader添加videoWriterInput。

代码语言:javascript复制
CGSize size = CGSizeMake(480, 960);

NSDictionary *videoSetDic = [NSDictionary dictionaryWithObjectsAndKeys:AVVideoCodecTypeH264,AVVideoCodecKey,
[NSNumber numberWithInt:size.width],AVVideoWidthKey,[NSNumber numberWithInt:size.height],AVVideoHeightKey,nil];

AVAssetWriterInput *videoWriterInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeVideo outputSettings:videoSetDic];

//将读取的图片内容添加到assetWriter                                              
if ([assetWriter canAddInput:videoWriterInput]) {
    [assetWriter addInput:videoWriterInput];
}

第三步:创建一个处理视频样本时专用的适配器对象,这个类在附加被包装为CVPixelBufferRef对象的视频样本时能提供最优性能。如果想要将CVPixelBufferRef类型对象添加到asset writer input,就需要使用AVAssetWriterInputPixelBufferAdaptor类。

代码语言:javascript复制
NSDictionary *pixelBufferAttributes = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:kCVPixelFormatType_32BGRA],kCVPixelBufferPixelFormatTypeKey,nil];

AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:videoWriterInput pixelBufferAttributes:pixelBufferAttributes];

第四步:音频数据的采集、添加音频输入

代码语言:javascript复制
//创建音频资源
AVURLAsset *audioAsset = [[AVURLAsset alloc] initWithURL:audioUrl options:nil];
//创建音频Track
AVAssetTrack *assetAudioTrack = [audioAsset tracksWithMediaType:AVMediaTypeAudio].firstObject;
//创建读取器 
AVAssetReader *assetReader = [[AVAssetReader alloc] initWithAsset:audioAsset error:&error];
//读取音频track中的数据
NSDictionary *audioSettings = @{AVFormatIDKey : [NSNumber numberWithUnsignedInt:kAudioFormatLinearPCM]};

AVAssetReaderTrackOutput *assetReaderAudioOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:assetAudioTrack outputSettings: audioSettings];
//向接收器添加assetReaderAudioOutput输出
if ([assetReader canAddOutput:assetReaderAudioOutput]) {
    [assetReader addOutput:assetReaderAudioOutput];
}

//音频通道数据,设置音频的比特率、采样率的通道数
AudioChannelLayout acl;
bzero( &acl, sizeof(acl));
acl.mChannelLayoutTag = kAudioChannelLayoutTag_Stereo;

NSData *channelLayoutAsData = [NSData dataWithBytes:&acl length:offsetof(AudioChannelLayout, acl)];

NSDictionary *audioSettings = @{AVFormatIDKey:[NSNumber numberWithUnsignedInt:kAudioFormatMPEG4AAC],AVEncoderBitRateKey:[NSNumber numberWithInteger:128000], AVSampleRateKey:[NSNumber numberWithInteger:44100], AVChannelLayoutKey:channelLayoutAsData,AVNumberOfChannelsKey : [NSNumber numberWithUnsignedInteger:2]};
//创建音频的assetWriterAudioInput,将读取的音频内容添加到assetWriter
AVAssetWriterInput *assetWriterAudioInput = [AVAssetWriterInput assetWriterInputWithMediaType:[assetAudioTrack mediaType] outputSettings: audioSettings];

if ([assetWriter canAddInput:assetWriterAudioInput]) {
    [assetWriter addInput:assetWriterAudioInput];
}

//Writer开始进行写入流程
[assetWriter startSessionAtSourceTime:kCMTimeZero];

3.2 转场切换效果中的图片处理

上面介绍了音视频合成的大致流程,但是核心的部分是在于我们在合成视频时,如何去写入第一张和第二张图片展示间隙中的切换过程效果。这个时候就得引入GPUImage这个底层框架,而GPUImage是iOS端对OpenGL的封装。即我们通过继承GPUImageFilter去实现自定义滤镜,并重写片元着色器的效果,通过如下代理回调得到这个过程中返回的一系列处理好的纹理样本数据。

代码语言:javascript复制
-(void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates;

然后转换成相应的pixelBuffer数据,通过调用appendPixelBuffer:添加到帧缓存中去,从而写入到文件中。

代码语言:javascript复制
-(BOOL)appendPixelBuffer:(CVPixelBufferRef)pixelBuffer withPresentationTime:(CMTime)presentationTime;

3.2.1 如何自定义滤镜

在GPUImageFilter中默认的着色器程序比较简单,只是简单的进行纹理采样,并没有对像素数据进行相关操作。所以在自定义相关滤镜的时候,我们通常需要自定义片段着色器的效果来处理纹理效果从而达到丰富的转场效果。

我们通过继承GPUImageFilter来自定义我们转场效果所需的滤镜,首先是创建一个滤镜文件compositeImageFilter继承于GPUImageFilter,然后重写父类的方法去初始化顶点和片段着色器。

代码语言:javascript复制
- (id)initWithVertexShaderFromString:(NSString *)vertexShaderString fragmentShaderFromString:(NSString *)fragmentShaderString;

这个时候需要传入所需的片元着色器代码,那么怎么自定义GLSL文件呢,以下便是如何编写具体的GLSL文件,即片元着色器实现代码。传入纹理的顶点坐标textureCoordinate、2张图片的纹理imageTexture、imageTexture2,通过mix函数混合两个纹理图像,使用time在[0,1]之间不停变化来控制第二个图片纹理混合的强弱变化从而实现渐变效果。

代码语言:javascript复制
precision highp float;
varying highp vec2 textureCoordinate;
uniform sampler2D imageTexture;
uniform sampler2D imageTexture2;
uniform mediump vec4 v4Param1;
float progress = v4Param1.x;
void main()
{
    vec4 color1 = texture2D(imageTexture, textureCoordinate);
    vec4 color2 = texture2D(imageTextur2, textureCoordinate);
    gl_FragColor = mix(color1, color2, step(1.0-textureCoordinate.x,progress));
}

3.2.2 了解GPUImageFilter中重点API

在GPUImageFilter中有三个最重要的API,GPUImageFilter会将接收到的帧缓存对象经过特定的片段着色器绘制到即将输出的帧缓存对象中,然后将自己输出的帧缓存对象传给所有Targets并通知它们进行处理。方法被调用的顺序:

1)生成新的帧缓存对象

代码语言:javascript复制
- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex;

2)进行纹理的绘制

代码语言:javascript复制
- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates;

3)绘制完成通知所有的target处理下一帧的纹理数据

代码语言:javascript复制
- (void)informTargetsAboutNewFrameAtTime:(CMTime)frameTime;

通过如上代理回调就可以得到这个过程中返回的一系列处理好的纹理样本数据。

按照方法调用顺序,我们一般先重写newFrameReadyAtTime方法,构建最新的顶点坐标,生成新的帧缓存对象。

代码语言:javascript复制
- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex {
    static const GLfloat imageVertices[] = {
        -1.0f, -1.0f,
        1.0f, -1.0f,
        -1.0f,  1.0f,
        1.0f,  1.0f,
    };
    [self renderToTextureWithVertices:imageVertices textureCoordinates:[[self class] textureCoordinatesForRotation:inputRotation]];
}

然后在这个方法中调用renderToTextureWithVertices去绘制所需的纹理,并获取到最终的帧缓存对象。以下是部分核心代码:

代码语言:javascript复制
- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates {
glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
glActiveTexture(GL_TEXTURE5);
glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]);
glUniform1i(filterInputTextureUniform, 5);
glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, [self adjustVertices:vertices]);
glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glFinish();
CVPixelBufferRef pixel_buffer = NULL;
CVReturn status = CVPixelBufferPoolCreatePixelBuffer(NULL, [self.videoPixelBufferAdaptor pixelBufferPool], &pixel_buffer);
if ((pixel_buffer == NULL) || (status != kCVReturnSuccess)) {
    CVPixelBufferRelease(pixel_buffer);
    return;
} else {
    CVPixelBufferLockBaseAddress(pixel_buffer, 0);
    GLubyte *pixelBufferData = (GLubyte *)CVPixelBufferGetBaseAddress(pixel_buffer);
    glReadPixels(0, 0, self.sizeOfFBO.width, self.sizeOfFBO.height, GL_RGBA, GL_UNSIGNED_BYTE, pixelBufferData);
CVPixelBufferUnlockBaseAddress(pixel_buffer, 0);
        }
    }
}

3.2.3 pixel_buffer的写入

在上述的处理过程当中,我们便获取到了所需的帧缓存样本数据pixel_buffer。而这个数据便是合成转场切换过程中的数据,我们把它进行写入,自此便完成了第一张和第二张图片转场效果效果的写入。待转场效果写入之后,我们便可按照此流程根据时间的进度写入第二张图片以及后续的第二张图片和第三张图片的转场效果。依此类推,一直到写完所有的图片。

代码语言:javascript复制
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
if (self.assetWriter.status != AVAssetWriterStatusWriting) {
    [self.assetWriter startWriting];
}
[self.assetWriter startSessionAtSourceTime:frameTime];
if (self.assetWriter.status == AVAssetWriterStatusWriting) {
    if (CMTIME_IS_NUMERIC(frameTime) == NO)  {
        CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
        return;
    }
    //确定写操作是否已完成、失败或已取消
    if ([self.videoPixelBufferAdaptor appendPixelBuffer:pixelBufferwithPresentationTime:frameTime]) {                                                    
        NSLog(@"%f", CMTimeGetSeconds(frameTime));
    }
}else {
    NSLog(@"status:%d", self.assetWriter.status);
    }
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);

以上便是在iOS端处理音视频合成的具体步骤,难点在于如何使用GPUImage去实现复杂的转场效果并将其写到到容器中。

本文介绍了音视频相关的基本知识,让大家对音视频的关键概念有了一些理解。然后分别介绍了Android和iOS这两个移动平台音视频编解码API,利用这些平台自带的API,我们可以将OpenGL渲染的画面编码成音视频文件。鉴于篇幅限制,文中的流程只截取了部分关键步骤的代码,欢迎大家来交流音视频相关的知识。

0 人点赞