总第354篇
2019年 第32篇
美美导读:移动互联网时代,4G的普及推动了移动视频的发展,丰富的视频内容满足了用户多样化的需求。美团外卖商家端也尝试引入了视频功能,旨在提升商品信息描述的丰富度。本文总结了商家端视频功能的闭环全流程实践及部分踩坑经验。
2013年美团外卖成立,至今一直迅猛发展。随着外卖业务量级与日俱增,单一的文字和图片已无法满足商家的需求,商家迫切需要更丰富的商品描述手段吸引用户,增加流量,进而提高下单转化率和下单量。商品视频的引入,在一定程度上可以提升商品信息描述丰富度,以更加直观的方式为商家引流,增加收益。为此,商家端引入了视频功能,进行了一系列视频功能开发,核心功能包含视频处理(混音,滤镜,加水印,动画等)、视频拍摄、合成等,最终效果图如下所示:
对于视频链路的开发,我们经历了方案选型、架构设计及优化、业务实践、功能测试、监控运维、更新维护等各个环节,核心环节如下图所示。在开发过程中,遇到了各种技术问题和挑战,下文会针对遇到的问题、挑战,及其解决方案进行重点阐述。
方案选型
在方案选型时,重点对核心流程和视频格式进行选型。我们以功能覆盖度、稳定性及效率、可定制性、成本及开源性做为核心指标,从而衡量方案的高可用性和可行性。
阿里和腾讯的云视频点播方案比较成熟,集成度高,且能力丰富,稳定性及效率也很高。但两者成本较高,需要收费,且SDK大小均在15M以上,对于我们的业务场景来说有些过于臃肿,定制性较弱,无法迅速的支持我们做定制性扩展。
当时的大众点评App UGC方案,基础能力是满足的,但因业务场景差异:
比如外卖的视频拍摄功能要求在竖屏下保证16:9的视频宽高比,这就需要对原有的采集区域进行截取,视频段落的裁剪支持不够等,业务场景的差异导致了实现方案存在巨大的差异,故放弃了大众点评App UGC方案。其他的一些开源方案(比如Grafika等),也无法满足要求,这里不再一一赘述。
通过技术调研和分析,吸取各开源项目的优点,并参考大众点评App UGC、Google CTS方案,对核心流程做了最终的方案选型,打造一个适合我们业务场景的方案,如下表所示:
采用H.264的视频协议:H.264的标准成熟稳定,普及率高。其最大的优势是具有很高的数据压缩比率,在同等图像质量的条件下,H.264的压缩比是MPEG-2的2倍以上,是MPEG-4的1.5~2倍。
采用AAC的音频协议:AAC是一种专为声音数据设计的文件压缩格式。它采用了全新的算法进行编码,是新一代的音频有损压缩技术,具有更加高效,更具有“性价比”的特点。
整体架构
我们整体的架构设计,用以满足业务扩展和平台化需要,可复用、可扩展,且可快速接入。架构采用分层设计,基础能力和组件进行下沉,业务和视频能力做分离,最大化降低业务方的接入成本,三方业务只需要接入视频基础SDK,直接使用相关能力组件或者工具即可。
整体架构分为四层,分别为平台层、核心能力层、基础组件层、业务层。
平台层:依赖系统提供的平台能力,比如Camera、OpenGL、MediaCodec和MediaMuxer等,也包括引入的平台能力,比如ijkplayer播放器、mp4parser。
核心能力层:该层提供了视频服务的核心能力,包括音视频编解码、音视频的转码引擎、滤镜渲染能力等。
基础能力层:暴露了基础组件和能力,提供了播放、裁剪、录屏等基础组件和对应的基础工具类,并提供了可定制的播放面板,可定制的缓存接口等。
业务层:包括段落拍摄、自由拍摄、视频空间、拍摄模版预览及加载等。
我们的视频能力层对业务层是透明的,业务层与能力层隔离,并对业务层提供了部分定制化的接口支持,这样的设计降低了业务方的接入成本,并方便业务方的扩展,比如支持蜜蜂App的播放面板定制,还支持缓存策略、编解码策略的可定制。整体设计如下图所示:
实践经验
在视频开发实践中,因业务场景的复杂性,我们遇到了多种问题和挑战。下面以核心功能为基点,围绕各功能遇到的问题做详细介绍。
播放器是视频播放基础。针对播放器,我们进行了一系列的方案调研和选择。在此环节,遇到的挑战如下:
针对兼容性问题,Android有原生的MediaPlayer,但其版本兼容问题偏多且支持格式有限,而我们需要支持播放本地视频,本地视频格式又无法控制,故该方案被舍弃。ijkplayer基于FFmpeg,与MediaPlayer相比,优点比较突出:具备跨平台能力,支持Android与iOS;提供了类似MediaPlayer的API,可兼容不同版本;可实现软硬解码自由切换,拥有FFmpeg的能力,支持多种流媒体协议。基于上述原因,我们最终决定选用ijkplayer。
但紧接着又发现ijkplayer本身不支持边缓存边播放,频繁的加载视频导致耗费大量的流量,且在弱网或者3G网络下很容易导致播放卡顿,所以这里就衍生出了缓存的问题。
针对缓存问题,引入AndroidVideoCache的技术方案,利用本地的代理去请求数据,先本地保存文件缓存,客户端通过Socket读取本地的文件缓存进行视频播放,这样就做到了边播放边缓存的策略,流程如下图:
此外,我们还对AndroidVideoCache做了一些技术改造:
优化缓存策略。针对缓存策略的单一性,支持有限的最大文件数和文件大小问题,调整为由业务方可以动态定制缓存策略;
解决内存泄露隐患。对其页面退出时请求不关闭会导致的内存泄露,为其添加了完整的生命周期监控,解决了内存泄露问题。
Camera+AudioRecord+MediaCodec+Surface
MediaRecorder+MediaCodec
方案1需要Camera采集YUV帧,进行截取采集,最后再将YUV帧和PCM帧进行编码生成mp4文件,虽然其效率高,但存在不可把控的风险。
方案2综合评估后是改造风险最小的。综合成本和风险考量,我们保守的采用了方案2,该方案是对裁剪区域进行坐标换算(如果用前置摄像头拍摄录制视频,会出现预览画面和录制的视频是镜像的问题,需要处理)。当录制完视频后,生成了mp4文件,用MediaCodec对其编码,在编码阶段再利用OpenGL做内容区域的裁剪来实现。但该方案又引发了如下挑战。
因我们对采集区域做了裁剪,引发了点触对焦问题。比如用户点击了相机预览画面,正常情况下会触发相机的对焦动作,但是用户的点击区域只是预览画面的部分区域,这就导致了相机的对焦区域错乱,不能正常进行对焦。后期经过问题排查,对点触区域再次进行相应的坐标变换,最终得到正确的对焦区域。
// 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技术完成视频裁剪,在实践中发现其精度误差存在很大的问题,甚至会影响正常的业务需求。比如禁止裁剪出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];
}
方案具体实施如下:先获得目标时间的上一帧信息,对视频解码,然后根据起始时间和截取时长进行切割,最后将裁剪后的音视频信息进行压缩编码,再封装进mp4容器中,这样我们的裁剪精度从秒级误差降低到微秒级误差,大大提高了容错率。
视频处理是整个视频能力最核心的部分,会涉及硬编解码(遵循OpenMAX框架)、OpenGL、音频处理等相关能力。
在实践过程中,我们遇到了一些需要特别注意的问题,比如开发时遇到的坑,严重的兼容性问题(包括硬件兼容性和系统版本兼容性问题)等。下面重点讲几个有代表性的问题。
查阅大量资料,也没能解释清楚这个异常的存在。基于日志错误信息,并通过系统源码定位,也只是发现是了和设置的参数不兼容导致的。经过反复的试错,最后确认是部分编解码器只支持偶数的视频宽高,所以我们对视频的宽高做了偶数限制。引起该问题的核心代码如下:
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;
}
......
}
我们在处理视频帧的时候,一开始获得的是从Camera读取到的基本的YUV格式数据,如果给编码器设置YUV帧格式,需要考虑YUV的颜色格式。这是因为YUV根据其采样比例,UV分量的排列顺序有很多种不同的颜色格式,Android也支持不同的YUV格式,如果颜色格式不对,会导致花屏等问题。
这也是硬编码中老生常谈的问题了,因为H264编码需要16*16的编码块大小。如果一开始设置输出的视频宽高没有进行16字节对齐,在某些设备(华为,三星等)就会出现绿边,或者花屏。
在最后的视频处理阶段,用户可以实时的看到加滤镜后的视频效果。这就需要对原始的视频帧进行二次处理,然后在播放器的Surface上渲染。首先我们需要OpenGL 的渲染环境(通过OpenGL的固有流程创建),渲染环境完成后就可以对视频的帧数据进行二次处理了。通过SurfaceTexture的updateTexImage接口,可将视频流中最新的帧数据更新到对应的GL纹理,再操作GL纹理进行滤镜、动画等处理。在处理视频帧数据的时候,首先遇到的是角度问题。在正常播放下(不利用OpenGL处理情况下)通过设置TextureView的角度(和视频的角度做转换)就可以解决,但是加了滤镜后这一方案就失效了。原因是视频的原始数据经过纹理处理再渲染到Surface上,单纯设置TextureView的角度就失效了,解决方案就是对OpenGL传入的纹理坐标做相应的旋转(依据视频的本身的角度)。
视频在二次渲染后会出现偶现的画面停滞现象,主要是SurfaceTexture的OnFrameAvailableListener不返回数据了。该问题的根本原因是GPU的渲染和视频帧的读取不同步,进而导致SurfaceTexture的底层核心BufferQueue读取Buffer出了问题。下面我们通过BufferQueue的机制和核心源码深入研究下:
首先从二次渲染的工作流程入手。从图像流(来自Camera预览、视频解码、GL绘制场景等)中获得帧数据,此时OnFrameAvailableListener会回调。再调用updateTexImage(),会根据内容流中最近的图像更新SurfaceTexture对应的GL纹理对象。我们再对纹理对象做处理,比如添加滤镜等效果。SurfaceTexture底层核心管理者是BufferQueue,本身基于生产者消费者模式。
SurfaceTexture的核心流程如下图:
status_t BufferQueueProducer::dequeueBuffer(int *outSlot,sp<android::Fence> *outFence, uint32_t width, uint32_t height,
PixelFormat format, uint32_t usage,FrameEventHistoryDelta* outTimestamps) {
......
int found = BufferItem::INVALID_BUFFER_SLOT;
while (found == BufferItem::INVALID_BUFFER_SLOT) {
status_t status = waitForFreeSlotThenRelock(FreeSlotCaller::Dequeue,
& found);
if (status != NO_ERROR) {
return status;
}
}
......
}
status_t BufferQueueProducer::waitForFreeSlotThenRelock(FreeSlotCaller caller,
int*found) const{
......
while (tryAgain) {
int dequeuedCount = 0;
int acquiredCount = 0;
for (int s : mCore -> mActiveBuffers) {
if (mSlots[s].mBufferState.isDequeued()) {
++dequeuedCount;
}
if (mSlots[s].mBufferState.isAcquired()) {
++acquiredCount;
}
}
// Producers are not allowed to dequeue more than
// mMaxDequeuedBufferCount buffers.
// This check is only done if a buffer has already been queued
if (mCore -> mBufferHasBeenQueued &&
dequeuedCount >= mCore -> mMaxDequeuedBufferCount) {
BQ_LOGE("%s: attempting to exceed the max dequeued buffer count "
"(%d)", callerString, mCore -> mMaxDequeuedBufferCount);
return INVALID_OPERATION;
}
}
.......
}
status_t ACodec::configureCodec(
const char *mime, const sp<AMessage> &msg) {
.......
if (encoder) {
if (mIsVideo || mIsImage) {
if (!findVideoBitrateControlInfo(msg, &bitrateMode, &bitrate, &quality)) {
return INVALID_OPERATION;
}
} else if (strcasecmp(mime, MEDIA_MIMETYPE_AUDIO_FLAC)
&& !msg->findInt32("bitrate", &bitrate)) {
return INVALID_OPERATION;
}
}
.......
}
static bool findVideoBitrateControlInfo(const sp<AMessage> &msg,
OMX_VIDEO_CONTROLRATETYPE *mode, int32_t *bitrate, int32_t *quality) {
*mode = getVideoBitrateMode(msg);
bool isCQ = (*mode == OMX_Video_ControlRateConstantQuality);
return (!isCQ && msg->findInt32("bitrate", bitrate))
|| (isCQ && msg->findInt32("quality", quality));
}
9.0前并无对CQ码流的强校验,如果不支持该码流也会使用默认支持的码流,
static OMX_VIDEO_CONTROLRATETYPE getBitrateMode(const sp<AMessage> &msg) {
int32_t tmp;
if (!msg->findInt32("bitrate-mode", &tmp)) {
return OMX_Video_ControlRateVariable;
}
return static_cast<OMX_VIDEO_CONTROLRATETYPE>(tmp);
}
关于码流还有个问题,就是如果通过系统的接口isBitrateModeSupported(int mode),判断是否支持该码流可能会出现误判,究其原因是framework层写死了该返回值,而并没有从硬件层或从media_codecs.xml去获取该值。关于码流各硬件厂商支持的差异性,可能谷歌也认为码流的兼容性太碎片化,不建议用非默认的码流。
音频处理还括对音频的混音、消声等操作。在混音操作的时候,还要注意音频文件的单声道转换等问题。
其实视频问题总结起来,大部分是都会牵扯到编解码(尤其是使用硬编码),需要大量的适配工作(以上也只是部分问题,碎片化还是很严峻的),所以就需要兜底容错方案,比如加入软编。
线上监控
我们以拍摄流程为例,来看看链路各核心节点的监控,如下图:
容灾降级
维护更新
视频功能上线后,经历了几个稳定的版本,保持着较高的成功率。但近期收到了Sniffer(美团内部监控系统)的邮件报警,发现视频处理链路的失败次数明显增多,通过Sniffer收集的信息发现大部分都是Android 9.0的问题(也就是上面讲的Android 9.0码流适配的问题),我们在商家端5.2版本进行了修复。该问题解决后,我们的视频处理链路成功率也恢复到了98%以上。
总结和规划
参考资料
mp4文件结构(一)、(二)、(三)、(四)
作者简介
金辉、李琼,美团外卖商家终端研发工程师。
---------- END ----------