Android/iOS视频编辑SDK开发记

开发移动端的视频编辑SDK已经三个月了,因为之前有过在PC上做实时视频合成的经验,所以乐观预期最短两个月就够了,因为主要的开发难点只在于Android和iOS移动平台相关的模块,以及视频合成的时间线控制。既然项目告一段落,也是时候总结一下那些日子踩过的坑了。

#蒹葭苍苍,白露为霜,所谓伊人在水一方

预期的平台包括:

1,Android;

2,iOS;

预期的主要功能包括:

1,视频裁剪;

2,视频拼接;

3,转场效果;

4,水印;

5,带动画背景的字幕;

6,配乐;

7,视频滤镜;

上面是从产品角度来看的功能,从开发的角度,其实包含:

1,视频播放

(1),不同媒体格式的解封装;

(2),基于平台硬件加速的视频解码,以及音频解码;

(3),视频缓冲,音频缓冲;

(4),时钟,时间线;

(5),视频渲染,音频渲染;

2,视频合成

(1),纹理更新;

(2),多级,多项,自由组合的合成架构;

(3),合成特效;

3,字幕

(1),文字;

(2),模板;

4,导出

(1),基于平台硬件加速的视频编码,以及音频编码;

(2),视频封装;

再反过来看,实际上这些功能在实现的同时也实现了音视频播放,音视频转码,画中画,画框,音效等其他功能,就看产品设计师发挥了。

#工欲善其事必先利其器

没用过苹果的任何产品,无奈公司标配 mac mini,请同事帮忙安装好操作系统,熟悉了一下系统环境。写到这忍不住要吐槽一下mac的“文件资源管理器”,之前用过的操作系统打开文件夹都是很方便的,新建文件也很方便,面对mac,欲哭无泪,原来要先点一下桌面空白的地方,等上面出现finder一栏,再点进入,再点电脑/个人之类才能进到文件夹,点进子文件夹不能返回上一层要按cmd+↑。说到这个cmd我又要吐槽一下,mac的cmd为什么是win键,复制粘贴不是ctrl+c和ctrl+v吗?还有新建文件,我一直都是借助于IDE完成的。好了,扯得有点远。

下载安装Android Studio,配置jni编译指令。以前用的eclipse,熟悉了一下Android Studio 开发环境,尤其工程配置与eclips不一样。Android Studio 全局搜索不太方便,装了一个惯用的 VSCode。mac的一个好处是可以和Linux里面一样使用命令行,Windows就比较麻烦要用mingw。

iOS开发毫无疑问使用xcode,向同事请教了iOS真机调试的配置问题,主要是签名问题。有一个坑,编译库时一定要把当前调试设备改成通用iOS设备,否则xcode只会编译当前调试机的CPU版本,导致其他人使用库时出architecture not found之类的错误。另外对于工程里的一些文件资源,安装时不一定会被拷贝到手机上,需要在build phace里面确认一下,没有的话就手动添加。期间还遇到个问题,最后定位到是因为上层使用了另外一个库内部用了ffmpeg与我们的库冲突了,最后通过编译成动态库解决了这个问题。

#荡胸生层云,决眦入归鸟

1,时间线

时间线是整个视频合成过程的逻辑轴心。什么时候该显示哪个视频,什么时候开始转场,什么时候暂停,什么时候播放,什么时候停止,用户做出seek操作了怎么重新部署,都应该在时间线上体现出来。一开始对于这个模块我的设想是这样的,将所有的视频或者主时间线上的图像看成一个“节目”,而时间线就是节目单,到上节目时间了就上节目,到转场时间了就开始转场。因为领导要求短时间内给出能看的东西,也就没多想,先做个样子出来再说。然而一个月后(Android基本功能都已实现)再回头来看,越发觉得不合适,不合适视频编辑的场景,因为在所有时间因素确定的情况下合成的最终结果因该是确定的,但时间线线程发出“上节目”指令或者“转场”指令并不能精确到帧,虽然合成线程按时间戳合成了,但仍然有可能不是正确的合成结果。于是我重新设计了一种模型,我称它为富播放器模型。也就是将短视频的合成看成一个播放器,视频线程和音频线程不断地以时间戳为准从“节目单”更新布局并合成出该时间戳的结果直至塞满视频缓冲区和音频缓冲区,由渲染线程按照一个全局共享的时钟按时间戳播放,如果出现性能不足以实时合成的情况就暂停时钟直至又有缓冲内容。它与播放器的区别在于,播放器是直接从文件解出帧,而这里是按照用户指定帧率和合成过程合成出来的帧。这个模型还有一个好处,就是可以极大地发挥音视频合成性能,在不需要渲染的情况下可以达到更高的帧率。于是后来又重构了一周时间,总算让自己满意了。

2,合成架构

因为我之前有相关的项目经验,所以节省了大量时间。


总之最终实现了一个线程安全的,可随心所欲修改合成方式的合成架构。

#剑阁峥嵘而崔嵬

1,解封装

开源的ffmpeg已经做得很好了,用在这里首先需要按照Adnroid或者iOS的方式编译配置好。由于ffmpeg不支持动图的seek功能,需要自己实现添加。对于只有一帧视频画面的视频或者是图片,调用ffmpeg的seek之后会读不出帧,需要在最开始把第一帧画面读出来缓存一下。

2,视频解码

(1),Android 硬件解码

最开始我使用的AMediaCodec接口,以避免在c++里调用Java接口,但后来要求说支持API19,没办法,重新使用MediaCodec实现了Android的硬件编解码模块。

Android默认优先创建硬件解码器,可以通过解码器的名字判断,一般com.google.的就是软件解码,硬件解码器的名字由厂家决定。创建解码器不能保证成功,在一款Android5.0的机上只能同时创建一路1080p解码器,可能这也是某些视频编辑软件实现的转场过程中只有一路画面在动的原因。但我相信随着硬件性能的不断提升,这在两三年后就不再是问题,而编辑的效果和内容才是永恒的主题。

输入队列,从队列里获取一个buffer,填入数据,塞回队列。尤其需要注意的是标志flags,传入spspps时使用BUFFER_FLAG_CODEC_CONFIG,传入普通视频帧时传0,否则会出错。

输出队列,从队列里获取一个buffer,如果需要读出到内存可以直接读出,但是这里我们不会这样做,因为显存和内存之间的数据传递是比较慢的,而且之后我们需要用这一帧数据去更新opengl纹理,也就是再次传回显存,两倍的慢,所以这里我们只记录下buffer信息不做数据处理,然后将调用releaseOutputBuffer将buffer塞回队列,塞回队列的时候有个参数render指定是否“显示”,如果buffer里没有图像数据这里传否,传是的话这一个buffer就会传给SurfaceTexture以供更新,通过onFrameAvailable回调会接收到这一过程的完成通知。onFrameAvailable和releaseOutputBuffer并不保证成对出现,但是updateTexImage必须要和onFrameAvailable成对调用,否则可能出现接收不到onFrameAvailable的情况。理所当然,纹理更新必须要在opengl的线程里执行,但是onFrameAvailable的回调会在任意线程被调用,所以除非学那些Android的例程一样死等onFrameAvailable里面发出消息,否则必须使用计数。Android的硬解码接口设计有个很大的缺陷,就是updateTexImage只能更新到最新的一帧画面(关于“最新”也有一个坑,后面细述),没有选择余地,这就导致缓存和复用上的麻烦,只能通过控制上游的releaseOutputBuffer来控制更新到的帧,如果多个releaseOutputBuffer只触发了一个onFrameAvailable,那就只能更新到最后一个buffer。当然想要多缓冲一些帧也不是没有办法,每release一个buffer就等onFrameAvailable回调,使用多个纹理交替绑定到SurfaceTexture,但这种方式损失了一定的原本异步的优势。之所以说它是设计缺陷,因为之前我也用过底层硬件厂家封装的编解码库,输入输出队列的方式如出一辙,但是底层的buffer由用户管理,可以自由的把某个输出buffer拿去更新纹理,只要放回队列的时候保证不再使用它就OK,也无需了从releaseOutputBuffer到onFrameAvailable再到updateTexImage的两个等待过程,无疑效率更高。上层设计把这种自由度和高效率给整没了。

上面说到,“updateTexImageTexImage只能更新到最新的一帧画面”,至于是onFrameAvailable时最后一个release的buffer,还是调用updateTexImage时最后一个release的buffer,在测试时发现并不确定,所以唯一能够确定updateTexImage到的是哪一帧只能由SurfaceTexture的时间戳来确定。但是!在Android5.0的一款手机上测试发现SurfaceTexture的时间戳始终是0,并没有按照buffer的时间戳更新,这就尴尬了。针对这种情况,就以自己维护的帧信息队列判断了。

Android解码的输出buffer是异步的,我曾经以为只要我把输入队列填满了,总能立即得到至少一个输出buffer,但事实上不一定。

updateTexImage的作用是把buffer里的图像数据更新到纹理,这个纹理是由glGenTextures申请,attachToGLContext的时候实际创建的OES纹理,颜色格式是nv12。注意detachFromGLContext会释放OES纹理内的实际内容,所以不能以为纹理更新好了,就可以与SurfaceTexture解绑了,否则做OpenGL合成的时候就会出错。

解码器在flush之后需要重新传入spspps信息,虽然所有buffer会在flush之后被收回,但onFrameAvailable相应需要调用的updateTexImage次数不能少。

另外要注意对Android解码器的访问不是线程安全的。

(2),iOS 硬件解码

iOS视频编解码无疑使用VideoToolBox。创建解码器格式的接口推荐使用CMVideoFormatDescriptionCreateFromH264ParameterSets,如果另一种方式CMVideoFormatDescriptionCreate即便随后传入spspps也不行。解码器的输入是CMSampleBuffer,它可以由CMSampleBufferCreateReady和CMBlockBuffer创建,这里的解码时间戳decodeTimeStamp只要是按照解码顺序递增的即可。VideoToolBox 解码出来的结果是CVImageBuffer,即CVPixelBuffer,颜色格式是NV12,并且包含颜色范围信息,可以决定到RGB的颜色转换矩阵参数。VideoToolBox有个众人皆知的特点(缺点?),解码出来的帧顺序就是解码的顺序,而不是像众多其他解码器一样以显示顺序输出,所以需要在输出端做个缓冲,避免因为B帧后出但时间戳却比之前的P帧小而造成跳帧或抖动的问题。至于输出端需要缓冲多少帧才能保证顺序,可以根据最大连续B帧数决定。

3,音频解码

音频处理完全是CPU的事情,相对就不那么复杂了,只是要注意合成必须保证多路音频的格式一致,主要是音频重采样,尤其注意原始采样率非常低的情况,重采样成高采样率后一帧数据会比较大。

4,视频渲染

(1),Android 视频渲染

Android实现了EGL接口,以EGLSurface作为输出窗口,在获得显示设备之后如果上层指定了SurfaceHold即EGLNativeWindow,可以由EGLNativeWindow创建EGLSurface,也可以创建PbufferSurface用于离屏渲染,那可不可以只渲染到纹理压根不使用EGLSurface呢?不可以!另外,在创建PbufferSurface时有个坑,必须显式地指定宽度和高度参数,且至少为1,否则在某些手机上就会出现EGL错误。在APP退出到后台时,surface会被销毁,所以OpenGL上下文也要销毁,否则会出错。

(2),iOS 视频渲染

iOS实现了一套自己的EAGL接口用于显示。在初始化EAGL时需要指定OpenGLES API版本,不同的API版本对后面OpenGLES的一些参数的选择会有影响。

5,音频渲染

(1),Android 音频渲染

因为音频数据的处理主要是在native层,所以优先考虑native方法,这里我们使用了opensles,另一个原因是我之前有做过,也没啥坑了。

(2),iOS 音频渲染

iOS音频渲染使用了AudioQueue,在没有音频数据输入的情况下,比如暂停,需要在渲染回调里填入静音数据,以避免之后放不出声音。

6,视频合成

(1),Android 视频合成

Android可以使用glTexImage2D从内存像纹理上传数据,本来EGLImage可以解决显存复用的问题,但新版本Android已经不支持EGLImage了。

(2),iOS 视频合成

有一个可以贯穿始终的显存buffer类型是平台加速的关键。iOS为我们提供了CVPixelBuffer。创建CVPixelBuffer时必须要注意参数pixelBufferAttributes,需要指定kCVPixelBufferIOSurfacePropertiesKey和kCVPixelBufferOpenGLESCompatibilityKey,否则后面用它来创建纹理时会出错。CVPixelBuffer可以通过CVPixelBufferLockBaseAddress写入或者读出数据,可以通过CVOpenGLESTextureCacheCreateTextureFromImage创建纹理(无拷贝),也可以作为iOS视频编码的输入数据。这里我通过自建CVPixelBuffer,以它创建纹理,并绑定到FBO,绘制完成后,就可以直接将CVPixelBuffer传给编码器,非常方便,也非常高效,数据只在显存中多次复用,无拷贝,无DMA。一开始我还担心给iOS编码器传入BGRA格式的数据不行,但事实发现是可以的。这与Android那边的EGLSwapBuffer提交数据给Android编码器如出一辙,都不需要自己做RGB到YUV的转换,非常方便。需要注意的是CVOpenGLESTextureCacheCreateTextureFromImage对于解码出来的NV12,则根据OpenGLES的API版本有所不同,OpenGLES2使用GL_RED/GL_RED_EXT, OpenGLES3使用GL_R8。另外CVPixelBuffer在创建时只支持kCVPixelFormatType_32BGRA,在整个合成过程中可以传RGBA数据进去当成RGBA来使用,但在编码之前一定要做BGRA转RGBA否则颜色就不对了。

7,音频合成

几乎与平台无关。音频合成的关键在于多路音频数据的叠加有可能导致合成后的数值大于采样位深能表示的最大值(比如16位深最大65535),这里我使用了自适应的办法解决。在合成过程中新加入一路音频会导致合成结果突然增大可能超过最大值,这里我使用了淡入淡出的办法解决。

8,视频编码

(1),Android 视频编码

Android默认优先创建硬件编码器,在资源不足时也可能创建失败。

Android硬件编码的数据传入方式比较特别,我曾经用过nvidia的Android多媒体接口,封装的非常漂亮,不管是编码还是解码,不管是视频还是音频,都是一样的调用方式,一个输入队列一个输出队列,不得不说如果选择走CPU,Mediacodec和AMediaCodec也比较漂亮,但要是追求效率当然只在GPU里面传递更好,Android的设计是这样子的――通过EGL用于显示的接口eglSwapBuffers去提交图像数据,在提交之前调用Android扩展接口EglPresentationTime设置时间戳。这个时间戳只能递增。一个隐藏的坑是,即便还没有调用编码器的start函数,eglSwapBuffers也会提交数据给编码器直到填满输入缓冲,并且调用start之后会被编码出来。Android编码器的stop和flush不能在start之前调。

(2),iOS 视频编码

iOS视频编码的输入是CVImageBuffer,即CVPixelBuffer,可以结合OpenGL纹理实现显存复用,无需走内存拷贝。

9,字幕

opengl绘制文字有多种方法,但要按照自己想要的字体绘制并且支持中文,DrawText或者光栅的方法就不适用了。我使用了freetype库来获得文字的纹理数据。特别值得一提的是文字位置大小的计算,文字与文字之间有字间距,行与行之间有行间距,这些都需要考虑到最终的文字大小中去。一个字符串的绘制最终是一个个字的纹理的绘制,所以为了提高效率,可以把字符串绘制到一个大小合适的纹理上,之后就只需要绘制这个单张纹理就好了,并且在移动或缩放时也更加方便。文字要加上背景动画才更好看,就涉及到几个问题――文字画在哪,文字多了怎么铺排,文字什么时候显示什么时候消失。关于文字的铺排基本有2种方式,一种是按照指定文字大小横向或纵向延展,可能会超出背景区域甚至超出屏幕;一种是固定文字区域,文字多了就减小单个文字的尺寸。在这里我用了第二种方式。

10,Android jni

JAVA调用C++方法相对比较容易,需要注意数据类型的转换,注意创建或引用了jvm对象用完之后要释放。C++调用JAVA方法则相对麻烦些,首先调用JAVA方法的线程一定要绑定到jvm上,在线程退出之前一定要解绑。使用线程私有数据的方法可以比较漂亮地解决这个问题。在线程退出时会自动调用析构回调函数,正好可以完成解绑。

常用JAVA数据类型的代号以及对应的jni数据类型如下:

void----V----void

long----J----jlong

int----I----jint

boolean----Z----jboolean

double----D----jdouble

string----Ljava/lang/String;----jstring

byte[]----[B----jbyteArray

#路漫漫其修远兮,吾将上下而求索

以上按模块总结了一些比较有印象的坑,难免疏漏,在这里聊且记录以待后来翻看;如能抛砖引玉就更好了!当然有必要的话我也愿意就每个模块做详细介绍。

至于开发时间,觉得还是比较紧凑的,何况中间还有个国庆节。如果说还有压缩的余地,那就不要指望一个月之内能看到Demo啦,所谓欲速则不达。

你可能感兴趣的:(Android/iOS视频编辑SDK开发记)