此外,我们还对AndroidVideoCache做了一些技术改造:
- 优化缓存策略。针对缓存策略的单一性,支持有限的最大文件数和文件大小问题,调整为由业务方可以动态定制缓存策略;
- 解决内存泄露隐患。对其页面退出时请求不关闭会导致的内存泄露,为其添加了完整的生命周期监控,解决了内存泄露问题。
视频录制
在视频拍摄的时候,最为常用的方式是采用MediaRecorder Camera技术,采集摄像头可见区域。但因我们的业务场景要求视频采集的时候,只录制采集区域的部分区域且比例保持宽高比16:9,在保证预览图像不拉伸的情况下,只能对完整的采集区域做裁剪,这无形增加了开发难度和挑战。通过大量的资料分析,重点调研了有两种方案:
- Camera AudioRecord MediaCodec Surface
- MediaRecorder MediaCodec
方案1需要Camera采集YUV帧,进行截取采集,最后再将YUV帧和PCM帧进行编码生成mp4文件,虽然其效率高,但存在不可把控的风险。
方案2综合评估后是改造风险最小的。综合成本和风险考量,我们保守的采用了方案2,该方案是对裁剪区域进行坐标换算(如果用前置摄像头拍摄录制视频,会出现预览画面和录制的视频是镜像的问题,需要处理)。当录制完视频后,生成了mp4文件,用MediaCodec对其编码,在编码阶段再利用OpenGL做内容区域的裁剪来实现。但该方案又引发了如下挑战。
(1)对焦问题
因我们对采集区域做了裁剪,引发了点触对焦问题。比如用户点击了相机预览画面,正常情况下会触发相机的对焦动作,但是用户的点击区域只是预览画面的部分区域,这就导致了相机的对焦区域错乱,不能正常进行对焦。后期经过问题排查,对点触区域再次进行相应的坐标变换,最终得到正确的对焦区域。
(2)兼容适配
我们的视频录制利用MediaRecorder,在获取配置信息时,由于Android碎片化问题,不同的设备支持的配置信息不同,所以就会出现设备适配问题。
// VIVO Y66 模版拍摄时候,播放某些有问题的视频文件的同时去录制视频,会导致MediaServer挂掉的问题 // 发现将1080P尺寸的配置降低到720P即可避免此问题 // 但是720P尺寸的配置下,又存在绿边问题,因此再降到480 if(isVIVOY66() && mMediaServerDied) { return getCamcorderProfile(CamcorderProfile.QUALITY_480P); } //SM-C9000,在1280 x 720 分辨率时有一条绿边。网上有种说法是GPU对数据进行了优化,使得GPU产生的图像分辨率 //和常规分辨率存在微小差异,造成图像色彩混乱,修复后存在绿边问题。 //测试发现,降低分辨率或者升高分辨率都可以绕开这个问题。 if (VideoAdapt.MODEL_SM_C9000.equals(Build.MODEL)) { return getCamcorderProfile(CamcorderProfile.QUALITY_HIGH); } // 优先选择 1080 P的配置 CamcorderProfile camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_1080P); if (camcorderProfile == null) { camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_720P); } // 某些机型上这个 QUALITY_HIGH 有点问题,可能通过这个参数拿到的配置是1080p,所以这里也可能拿不到 if (camcorderProfile == null) { camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_HIGH); } // 兜底 if (camcorderProfile == null) { camcorderProfile = getCamcorderProfile(CamcorderProfile.QUALITY_480P); }
视频合成
我们的视频拍摄有段落拍摄这种场景,商家可根据事先下载的模板进行分段拍摄,最后会对每一段的视频做拼接,拼接成一个完整的mp4文件。mp4由若干个Box组成,所有数据都封装在Box中,且Box可再包含Box的被称为Container Box。mp4中track表示一个视频或音频序列,是Sample的集合,而Sample又可分为Video Smaple和Audio Sample。Video Smaple代表一帧或一组连续视频帧,Audio Sample即为一段连续的压缩音频数据。(详见mp4文件结构。)
基于上面的业务场景需要,视频合成的基础能力我们采用mp4parser技术实现(也可用FFmpeg等其他手段)。mp4parser在拼接视频时,先将视频的音轨和视频轨进行分离,然后进行视频和音频轨的追加,最终将合成后的视频轨和音频轨放入容器里(这里的容器就是mp4的Box)。采用mp4parser技术简单高效,API设计简洁清晰,满足需求。
但我们发现某些被编码或处理过的mp4文件可能会存在特殊的Box,并且mp4parser是不支持的。经过源码分析和原因推导,发现当遇到这种特殊格式的Box时,会申请分配一个比较大的空间用来存放数据,很容易造成OOM(内存溢出),见下图所示。于是,我们对这种拼接场景下做了有效规避,仅在段落拍摄下使用mp4parser的拼接功能,保证处理过的文件不会包含这种特殊的Box。
视频裁剪
我们刚开始采用mp4parser技术完成视频裁剪,在实践中发现其精度误差存在很大的问题,甚至会影响正常的业务需求。比如禁止裁剪出3s以下的视频,但是由于mp4parser产生的精度误差,导致4-5s的视频很容易裁剪出少于3s的视频。究其原因,mp4parser只能在关键帧(又称I帧,在视频编码中是一种自带全部信息的独立帧)进行切割,这样就可能存在一些问题。比如在视频截取的起始时间位置并不是关键帧,会造成误差,无法保证精度而且是秒级误差。以下为mp4parser裁剪的关键代码:
public static double correctTimeToSyncSample(Track track, double cutHere, boolean next) { double[] timeOfSyncSamples = new double[track.getSyncSamples().length]; long currentSample = 0; double currentTime = 0; for (int i = 0; i < track.getSampleDurations().length; i ) { long delta = track.getSampleDurations()[i]; int index = Arrays.binarySearch(track.getSyncSamples(), currentSample 1); if (index >= 0) { timeOfSyncSamples[index] = currentTime; } currentTime = ((double) delta / (double) track.getTrackMetaData().getTimescale()); currentSample ; } double previous = 0; for (double timeOfSyncSample : timeOfSyncSamples) { if (timeOfSyncSample > cutHere) { if (next) { return timeOfSyncSample; } else { return previous; } } previous = timeOfSyncSample; } return timeOfSyncSamples[timeOfSyncSamples.length - 1]; }
为了解决精度问题,我们废弃了mp4parser,采用MediaCodec的方案,虽然该方案会增加复杂度,但是误差精度大大降低。
方案具体实施如下:先获得目标时间的上一帧信息,对视频解码,然后根据起始时间和截取时长进行切割,最后将裁剪后的音视频信息进行压缩编码,再封装进mp4容器中,这样我们的裁剪精度从秒级误差降低到微秒级误差,大大提高了容错率。
视频处理
视频处理是整个视频能力最核心的部分,会涉及硬编解码(遵循OpenMAX框架)、OpenGL、音频处理等相关能力。
下图是视频处理的核心流程,会先将音视频做分离,并行处理音视频的编解码,并加入特效处理,最后合成进一个mp4文件中。
在实践过程中,我们遇到了一些需要特别注意的问题,比如开发时遇到的坑,严重的兼容性问题(包括硬件兼容性和系统版本兼容性问题)等。下面重点讲几个有代表性的问题。
1. 偶数宽高的编解码器
视频经过编码后输出特定宽高的视频文件时出现了如下错误,信息里仅提示了Colorformat错误,具体如下:
查阅大量资料,也没能解释清楚这个异常的存在。基于日志错误信息,并通过系统源码定位,也只是发现是了和设置的参数不兼容导致的。经过反复的试错,最后确认是部分编解码器只支持偶数的视频宽高,所以我们对视频的宽高做了偶数限制。引起该问题的核心代码如下:
status_t ACodec::setupVideoEncoder(const char *mime, const sp<AMessage> &msg, sp<AMessage> &outputFormat, sp<AMessage> &inputFormat) { if (!msg->findInt32("color-format", &tmp)) { return INVALID_OPERATION; } OMX_COLOR_FORMATTYPE colorFormat = static_cast<OMX_COLOR_FORMATTYPE>(tmp); status_t err = setVideoPortFormatType( kPortIndexInput, OMX_VIDEO_CodingUnused, colorFormat); if (err != OK) { ALOGE("[%s] does not support color format %d", mComponentName.c_str(), colorFormat); return err; } ....... } status_t ACodec::setVideoPortFormatType(OMX_U32 portIndex,OMX_VIDEO_CODINGTYPE compressionFormat, OMX_COLOR_FORMATTYPE colorFormat,bool usingNativeBuffers) { ...... for (OMX_U32 index = 0; index <= kMaxIndicesToCheck; index) { format.nIndex = index; status_t err = mOMX->getParameter( mNode, OMX_IndexParamVideoPortFormat, &format, sizeof(format)); if (err != OK) { return err; } ...... }
2. 颜色格式
我们在处理视频帧的时候,一开始获得的是从Camera读取到的基本的YUV格式数据,如果给编码器设置YUV帧格式,需要考虑YUV的颜色格式。这是因为YUV根据其采样比例,UV分量的排列顺序有很多种不同的颜色格式,Android也支持不同的YUV格式,如果颜色格式不对,会导致花屏等问题。
3. 16位对齐
这也是硬编码中老生常谈的问题了,因为H264编码需要16*16的编码块大小。如果一开始设置输出的视频宽高没有进行16字节对齐,在某些设备(华为,三星等)就会出现绿边,或者花屏。
4. 二次渲染
4.1 视频旋转
在最后的视频处理阶段,用户可以实时的看到加滤镜后的视频效果。这就需要对原始的视频帧进行二次处理,然后在播放器的Surface上渲染。首先我们需要OpenGL 的渲染环境(通过OpenGL的固有流程创建),渲染环境完成后就可以对视频的帧数据进行二次处理了。通过SurfaceTexture的updateTexImage接口,可将视频流中最新的帧数据更新到对应的GL纹理,再操作GL纹理进行滤镜、动画等处理。在处理视频帧数据的时候,首先遇到的是角度问题。在正常播放下(不利用OpenGL处理情况下)通过设置TextureView的角度(和视频的角度做转换)就可以解决,但是加了滤镜后这一方案就失效了。原因是视频的原始数据经过纹理处理再渲染到Surface上,单纯设置TextureView的角度就失效了,解决方案就是对OpenGL传入的纹理坐标做相应的旋转(依据视频的本身的角度)。
4.2 渲染停滞
视频在二次渲染后会出现偶现的画面停滞现象,主要是SurfaceTexture的OnFrameAvailableListener不返回数据了。该问题的根本原因是GPU的渲染和视频帧的读取不同步,进而导致SurfaceTexture的底层核心BufferQueue读取Buffer出了问题。下面我们通过BufferQueue的机制和核心源码深入研究下:
首先从二次渲染的工作流程入手。从图像流(来自Camera预览、视频解码、GL绘制场景等)中获得帧数据,此时OnFrameAvailableListener会回调。再调用updateTexImage(),会根据内容流中最近的图像更新SurfaceTexture对应的GL纹理对象。我们再对纹理对象做处理,比如添加滤镜等效果。SurfaceTexture底层核心管理者是BufferQueue,本身基于生产者消费者模式。
BufferQueue管理的Buffer状态分为:FREE、DEQUEUED、QUEUED、ACQUIRED、SHARED。当Producer需要填充数据时,需要先Dequeue一个Free状态的Buffer,此时Buffer的状态为DEQUEUED,成功后持有者为Producer。随后Producer填充数据完毕后,进行Queue操作,Buffer状态流转为QUEUED,且Owner变为BufferQueue,同时会回调BufferQueue持有的ConsumerListener的onFrameAvailable,进而通知Consumer可对数据进行二次处理了。Consumer先通过Acquire操作,获取处于QUEUED状态的Buffer,此时Owner为Consumer。当Consumer消费完Buffer后,会执行Release,该Buffer会流转回BufferQueue以便重用。BufferQueue核心数据为GraphicBuffer,而GraphicBuffer会根据场景、申请的内存大小、申请方式等的不同而有所不同。
SurfaceTexture的核心流程如下图: