一、前言
众所周知,ExoPlayer播放架构中,默认使用MediaCodec框架去解码和渲染。但实际上ExoPlayer作为一款开源播放器,具备强大的扩展能力,其本身还支持解码器扩展和渲染器扩展。比如可以使用ExoPlayer + Ffmpeg实现音视频解码和播放,同时也支持vp9、av1、flac等解码器和渲染器。因此,作为开发者,对ExoPlayer的学习不应该局限于MediaCodec的使用。
综上所说,在使用ExoPlayer时,你的选择范围很大,当然这点也取决于你对ExoPlayer的熟悉程度。
我们知道,MediaCodec支持两种模式——Buffer模式(兼容性好)和Surface模式(性能好),但是ExoPlayer中的使用MediaCodec视频解码时仅支持Surface模式,这种可能是出于性能考虑。
但是有一些比较特殊的情况,需要对画面加工、检测调试,或者提高兼容性的考虑,需要实现Buffer模式。
1.1 意义
ExoPlayer中,视频解码部分,出于性能原因,MediaCodec不支持Buffer模式,即便不传入Surface,其内部也会创建PlaceHolderSurface用于兜底。
但是实现Buffer模式的方式也是有多种的,最简单的是通过ImageReader去实现YUV读取,但是作为开发者,仍然要做的是需要设置Color-Format的,不然有些设备无法拿到YUV数据.
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatYUV420Flexible);
不过,本篇我们会对ExoPlayer进行改造,这里我们应该思考,我们对于ExoPlayer的改造意义何在呢?
相比而言,ImageReader的性能会稍微差一些,实现流程也比较复杂,如果将ImageReader的数据用于渲染,这个链路和流程相比也会多一点。
因此选择直接处理,反而性能可以有所保证,这就是我们改造ExoPlayer而不是使用imageReader的原因。
1.2 目标
我们这里就不用ImageReader或者egl的GetPixels方式了,这里我们选择使用MediaCodec#getOutputBuffer后直接处理数据,使得ExoPlayer中的MediaCodec既能支持Surface模式,又能支持Buffer模式。
二、渲染器和解码器扩展
2.1 约束
ExoPlayer内部提供了扩展解码器的一些约束和规范
顶层规范是com.google.android.exoplayer2.BaseRenderer,其内部约定了基础的调用流程,次一级的DecoderVideoRenderer和DecoderAudioRenderer,提供了常用的渲染器扩展流程。比较经典的是vp9和ffmpeg的实现,具体demo可以参考下面的实现。
com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer
com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer
当然,官方的扩展中并不包含FfmpegVideoRenderer的实现,当然更早期的ExoPlayer有Ffmpeg视频解码的实现,后来完全删除了,可能原因和视频解码的开源协议(LGPL)有关,因此,这部需求可能需要自行实现。
2.2 输出模式
在ExoPlayer音频解码本身就是Buffer模式,但是对于视频而言,这点有所区别,我们知道,MediaCode视频解码支持两种模式,Buffer模式和Surface模式,区别是MediaCodec#configure(...)方法中有没有传入Surface,有的话就是Surface模式,没有就是Buffer模式。Surface模式时MediaCodec#getOutputBuffer(...)拿到的Buffer中的所有数据都是“0”填充的。
当然ExoPlayer内部也有定义了相关标记
/** Video decoder 无输出. */
public static final int VIDEO_OUTPUT_MODE_NONE = -1;
/** Video decoder yuv420 模式. */
public static final int VIDEO_OUTPUT_MODE_YUV = 0;
/** Video decoder yuv420 surface模式. */
public static final int VIDEO_OUTPUT_MODE_SURFACE_YUV = 1;
但是ExoPlayer只支持MediaCodecVideoRenderer只Surface模式,那么如果要实现Buffer模式支持,该如何做呢?
2.3 扩展方案
我们前面说过,使用DecoderVideoRenderer就是实现视频解码的模式扩展,这种方法理论上是可以的,但是官方做提供的MediaCodecVideoRenderer做了很多相关的优化,如果单纯使用DecoderVideoRenderer去实现,会发现有很多重复性的冗余工作,而且SimpleDecoder适配起来反而有些复杂和啰嗦。
因此,这里我们建议改造MediaCodecVideoRenderer,但是作为官方的代码,虽然继承其可以实现自己的Renderer,但是仍然不够巧妙,毕竟有些逻辑依赖了Surface。
我们这里直接复制一份MediaCodecVideoRenderer代码,命名成MediaCodecVideoAdaptiveRenderer,在其基础上改造。
三、逻辑
3.1 定义变量
首先新增两个变量,用于保存要输出到的目标,这里我们沿用官方的VideoDecoderOutputBufferRenderer,其主要实现子类是VideoDecoderGLSurfaceView,该组件主要通过YUV数据进行UI渲染。
我们MediaCodecVideoAdaptiveRenderer类中添加如下代码
// Surface 或者 VideoDecoderOutputBufferRenderer,用于屏蔽差异
@Nullable private Object output;
//buffer模式渲染器
@Nullable private VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer;
3.2 改造setOutput方法
默认的该方法只支持setOutput,我们对其进行修改,使得其支持VideoDecoderOutputBufferRenderer
调整MediaCodecVideoAdaptiveRenderer的setOutput方法。
private void setOutput(@Nullable Object output) throws ExoPlaybackException {
// Handle unsupported (i.e., non-Surface) outputs by clearing the surface.
@Nullable Surface surface = null;
@Nullable VideoDecoderOutputBufferRenderer outputBufferRenderer = null; //新增
if(output instanceof Surface){
surface = (Surface) output;
outputBufferRenderer = null;
outputMode = C.VIDEO_OUTPUT_MODE_SURFACE_YUV; //surface模式
}else if(output instanceof VideoDecoderOutputBufferRenderer){
surface = null;
outputBufferRenderer = (VideoDecoderOutputBufferRenderer) output;
outputMode = C.VIDEO_OUTPUT_MODE_YUV; //buffer 模式
}else{
//只解码,解码后扔掉数据
output = null;
surface = null;
outputBufferRenderer = null;
outputMode = C.VIDEO_OUTPUT_MODE_NONE;
}
this.output = output;
if (surface == null && outputBufferRenderer == null) {
// Use a placeholder surface if possible.
if (placeholderSurface != null) {
surface = placeholderSurface; //兜底逻辑,方便配合后续逻辑丢帧
} else {
MediaCodecInfo codecInfo = getCodecInfo();
if (codecInfo != null && shouldUsePlaceholderSurface(codecInfo)) {
placeholderSurface = PlaceholderSurface.newInstanceV17(context, codecInfo.secure);
surface = placeholderSurface;
outputMode = C.VIDEO_OUTPUT_MODE_YUV;
}
}
}
// 省略原有的一些代码
if(this.videoDecoderOutputBufferRenderer != outputBufferRenderer){
this.videoDecoderOutputBufferRenderer = outputBufferRenderer;
maybeRenotifyVideoSizeChanged(); //切换outputBufferRenderer 时通知用户
maybeRenotifyRenderedFirstFrame();
}
}
2.3 支持Color-Format
如果MediaCodec不用Surface渲染,那么就是Buffer模式,然而,这里有个和ImageReader#getSurface都可能出现的问题,就是部分设备读取不到合适的YUV数据,因此,在Buffer模式下,需要设置Color-Format。
调整MediaCodecVideoAdaptiveRenderer的getMediaFormat方法。
protected MediaFormat getMediaFormat(
Format format,
String codecMimeType,
CodecMaxValues codecMaxValues,
float codecOperatingRate,
boolean deviceNeedsNoPostProcessWorkaround,
int tunnelingAudioSessionId) {
//省略一些代码
if(outputMode == C.VIDEO_OUTPUT_MODE_YUV) {
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatYUV420Flexible);
}
//还得省略一些代码
}
2.4 渲染
下面我们修改两个渲染方法,使得MediaCodec#在Buffer模式时丢帧,防止无法渲染。
另外我们需要定义一个方法onDrainOutputBuffer用于处理Buffer数据
private void onDrainOutputBuffer(MediaCodecAdapter codec,ByteBuffer outputBuffer, int index, long presentationTimeUs) {
if(outputBuffer != null){
MediaFormat outputFormat = codec.getOutputFormat();
int colorFormat = outputFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT);
int width = outputFormat.getInteger(MediaFormat.KEY_WIDTH);
int height = outputFormat.getInteger(MediaFormat.KEY_HEIGHT);
int alignWidth = width;
int alignHeight = height;
int stride = outputFormat.getInteger(MediaFormat.KEY_STRIDE);
int sliceHeight = outputFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT);
if (stride > 0 && sliceHeight > 0) {
alignWidth = stride;
alignHeight = sliceHeight;
}
// alignWidth = alignTo16(alignWidth); //不满足16倍数,时对齐
// alignHeight = alignTo16(alignHeight); //不满足16倍数,时对齐
int remaining = outputBuffer.remaining();
Buffer yuvDataBuffer = bufferPool.obtain(remaining);
outputBuffer.get(yuvDataBuffer.getBuffer()); //第一次数据拷贝
switch (colorFormat){
case CodecCapabilities.COLOR_FormatYUV420Flexible: //这种情况也按420p处理
case CodecCapabilities.COLOR_FormatYUV420Planar:
case CodecCapabilities.COLOR_FormatYUV420PackedPlanar:
//android 这里是I420格式,直接使用
yuvDataBuffer.setDataSize(remaining); //这里直接设置大小即可
break;
case CodecCapabilities.COLOR_FormatYUV420SemiPlanar:
{
// YUV420是4:1:1 alignWidth * alignHeight + alignWidth * alignHeight /4 + alignWidth * alignHeight/4
Buffer yuvData420P = bufferPool.obtain(alignWidth * alignHeight * 3 / 2);
YuvTools.yuvNv21ToYuv420P(yuvDataBuffer.getBuffer(),yuvData420P.getBuffer(),alignWidth,alignHeight);
//第二次数据拷贝
yuvData420P.setDataSize(alignWidth * alignHeight * 3 / 2);
yuvDataBuffer.recycle();
yuvDataBuffer = yuvData420P;
}
break;
case CodecCapabilities.COLOR_FormatYUV420PackedSemiPlanar:{
// YUV420是4:1:1 alignWidth * alignHeight + alignWidth * alignHeight /4 + alignWidth * alignHeight/4
Buffer yuvData420P = bufferPool.obtain(alignWidth * alignHeight * 3 / 2);
YuvTools.yuvNv12ToYuv420P(yuvDataBuffer.getBuffer(),yuvData420P.getBuffer(),alignWidth,alignHeight);
//第二次数据拷贝
yuvData420P.setDataSize(alignWidth * alignHeight * 3 / 2);
yuvDataBuffer.recycle();
yuvDataBuffer = yuvData420P;
}
break;
}
VideoDecoderOutputBufferWrapper decoderOutputBuffer = new VideoDecoderOutputBufferWrapper(new DecoderOutputBuffer.Owner<VideoDecoderOutputBuffer>() {
@Override
public void releaseOutputBuffer(VideoDecoderOutputBuffer outputBuffer) {
if(outputBuffer instanceof VideoDecoderOutputBufferWrapper){
byteBufferPool.recycle(((VideoDecoderOutputBufferWrapper) outputBuffer).bufferHolder); //回收ByteBuffer
}
}
});
decoderOutputBuffer.init(presentationTimeUs,C.VIDEO_OUTPUT_MODE_YUV,null);
boolean isDebug = false;
ByteBufferHolder bufferHolder = byteBufferPool.obtain(yuvDataBuffer.getDataSize());
byte[] yuvData = yuvDataBuffer.getBuffer();
decoderOutputBuffer.bufferHolder = bufferHolder;
decoderOutputBuffer.data = bufferHolder.getBuffer();
//第三次数据拷贝
decoderOutputBuffer.data.put(yuvData,0,yuvDataBuffer.getDataSize());
decoderOutputBuffer.initForYuvFrame(width,height,stride,stride / 2,0);
if(isDebug) {
Bitmap bitmap = YuvTools.toBitmap(yuvData,width, height);
Log.d(TAG,"Bitmap = " + bitmap);
}
yuvDataBuffer.recycle();
VideoDecoderOutputBufferRenderer bufferRenderer = videoDecoderOutputBufferRenderer;
if(bufferRenderer != null){
bufferRenderer.setOutputBuffer(decoderOutputBuffer);
}
outputBuffer = null;
}
}
当然,上面我们用到了两个池化Buffer,一个byte数组,另一个是ByteBuffer,这部分内容后续我们在性能优化部聊一下。
当然,在上面代码中我们可以看到,OutputFormat中拿到的Color-Format也可能是COLOR_FormatYUV420Flexible,这种情况其实也只需要安装420p处理即可。
private final BufferPool bufferPool = new BufferPool("BufferMode",3,false);
private final ByteBufferPool byteBufferPool = new ByteBufferPool("BufferMode",5,false);
当然,上面仍然存在过度的拷贝,因此仍然需要进一步优化。
此外,对于VideoDecoderOutputBuffer,我们为了回收ByteBuffer,显然有必要Wrapper一下,上面的代码中我们就用到了
public class VideoDecoderOutputBufferWrapper extends VideoDecoderOutputBuffer {
public ByteBufferHolder bufferHolder;
/**
* Creates VideoDecoderOutputBuffer.
* @param owner Buffer owner.
*/
public VideoDecoderOutputBufferWrapper(Owner<VideoDecoderOutputBuffer> owner) {
super(owner);
}
}
YuvTools中的yuv420sp转yuv420p的逻辑,这部分是java实现的,可能性能差一些,但是高性能设备上也是可以的。
因为COLOR_FormatYUV420PackedSemiPlanar为Nv12,因此UV排列是YYYYUVUV,因此需要做如下转换
public static void yuvNv12spToYuv420P(byte[] yuv420spData, byte[] yuv420pData, int width, int height) {
final int ySize = width * height;
System.arraycopy(yuv420spData, 0, yuv420pData, 0, ySize); //拷贝 Y 分量
int i = ySize;
int j = ySize;
int limit = ySize * 3 / 2;
while (i < limit) {
int index = i;
if (i >= yuv420spData.length - 1) {
index = yuv420spData.length - 2;
}
yuv420pData[j] = yuv420spData[index];
yuv420pData[j + ySize / 4] = yuv420spData[index + 1];
i += 2;
j++;
}
}
因为COLOR_FormatYUV420SemiPlanar为Nv21格式,因此UV排列是YYYYVUVU,下面为转换I420的代码
public static void yuvNv21ToYuv420P(ByteBuffer yuv420spData, ByteBuffer yuv420pData, int width, int height) {
final int ySize = width * height;
final int uvSize = ySize / 4;
final int totalSize = width * height * 3 / 2;
yuv420pData.position(0);
yuv420spData.position(0);
yuv420spData.limit(Math.min(ySize, yuv420spData.limit()));
yuv420pData.put(yuv420spData);
yuv420spData.limit(totalSize);
int i = ySize;
int j = ySize;
int vPlaneStart = ySize + uvSize;
int limit = ySize + (ySize / 2);
while (i < limit) {
int index = i;
if (i >= yuv420spData.limit() - 1) {
break;
}
yuv420pData.put(j, yuv420spData.get(index + 1)); // NV21 index + 1 是 U
yuv420pData.put(vPlaneStart + (j - ySize), yuv420spData.get(index)); // NV21 index 是 V
i += 2;
j++;
}
}
不过这里要补充一下,这种方式对一些iot 设备也不够理想,因此,如果是buffer,建议只播720p及以下分辨率的资源,其次我们可以使用libyuv 对yuv的分辨率进行压缩,以提高性能。
2.5 问题补充
这里一些关注点,改造时可能遇到的问题,方便大家阅读。
2.5.1 colorSpace
initForYuvFrame方法最后一个参数是colorspace,用于调整画质,可以理解为色彩的饱和度、亮度等调整,这里我们无法从MediaCodec拿到这个,这个参数传入0,直接使用COLORSPACE_BT709画质即可。
2.5.2 VideoDecoderGLSurfaceView
这个是官方的YUV渲染实现,代码就不贴出来了
2.5.3 COLOR_FormatYUV420Flexible
设置的是COLOR_FormatYUV420Flexible,为什么解码出来时420sp或者420p呢?
主要是COLOR_FormatYUV420Flexible是用于兼容原有格式,原来的格式google都废弃掉了,还有个原因是一些解码器并不会因为你设置了例如COLOR_FormatYUV420Planar就会给你COLOR_FormatYUV420Planar,因此官方最终统一了实现逻辑。
当然,这里还有个问题是,目前只能默认转换为I420,但YV12无法识别的,因为MediaCodec中没有定义此长量,但目前而言,似乎很少见到YV12的类型输出。
2.5.3 Buffer帧完整性
可能有人会比较疑惑,解码后帧是不是完整的,实际上解码后的是完整的帧,并不是B帧或者P帧,因此每一帧都可以看做是IDR帧
2.5.3 YUV数据校验
很多时候,对于处理一些转换逻辑,需要查验帧的正确性,这个时候就需要转成Bitmap,当然也有更好的工具,不过大部分收费。
四、使用
下面我们将MediaCodecVideoAdaptiveRenderer接入播放器内部
4.1 接入
上面的核心逻辑实现了,那么怎么才能接入呢?
这里我们需要改造 com.google.android.exoplayer2.RenderersFactory代码,当然,继承DefaultRenderersFactory更加方便
@Override
protected void buildVideoRenderers(Context context,
@ExtensionRendererMode int extensionRendererMode, MediaCodecSelector mediaCodecSelector,
boolean enableDecoderFallback, Handler eventHandler, VideoRendererEventListener eventListener,
long allowedVideoJoiningTimeMs, ArrayList<Renderer> out) {
MediaCodecVideoAdaptiveRenderer videoRenderer =
new MediaCodecVideoAdaptiveRenderer(
context,
getCodecAdapterFactory(),
mediaCodecSelector,
allowedVideoJoiningTimeMs,
enableDecoderFallback,
eventHandler,
eventListener,
MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY);
out.add(videoRenderer);
//省略一些代码
}
通过下面方法接入到播放器内部
private void setRenderersFactory(
ExoPlayer.Builder playerBuilder, boolean preferExtensionDecoders) {
RenderersFactory renderersFactory =
DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders);
playerBuilder.setRenderersFactory(new DemoDefaultRendererFactory(getApplicationContext()));
}
4.2 效果
下面是渲染效果,同样seek操作也是不会影响的,画面渲染还可,也不见得很卡。
五、总结
好了,本篇主要内容就到这里,实际上我们讲解的比较粗略,主要是篇幅内容太多,不适合学习。其实一方面我们实现了ExoPlayer+MediaCodec视频解码Buffer模式支持,另一方面我们可以看到ExoPlayer高度的可扩展性,相比而言,非常适合Android开发者学习。
通过本篇我们了解MediaCodec、ExoPlayer一些模式,其实MediaCodec和Ffmpeg本质上是同一级别的多媒体框架,而ExoPlayer属于产品几遍了,后续我们实现下ExoPlayer+Ffmpeg视频解码,方便大家进一步对比MediaCodec。