ExoPlayer MediaCodec视频解码Buffer模式支持

2,237 阅读10分钟

一、前言

众所周知,ExoPlayer播放架构中,默认使用MediaCodec框架去解码和渲染。但实际上ExoPlayer作为一款开源播放器,具备强大的扩展能力,其本身还支持解码器扩展和渲染器扩展。比如可以使用ExoPlayer + Ffmpeg实现音视频解码和播放,同时也支持vp9、av1、flac等解码器和渲染器。因此,作为开发者,对ExoPlayer的学习不应该局限于MediaCodec的使用。

综上所说,在使用ExoPlayer时,你的选择范围很大,当然这点也取决于你对ExoPlayer的熟悉程度。

企业微信20240930-142251@2x.png

我们知道,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操作也是不会影响的,画面渲染还可,也不见得很卡。

fire_175.gif

五、总结

好了,本篇主要内容就到这里,实际上我们讲解的比较粗略,主要是篇幅内容太多,不适合学习。其实一方面我们实现了ExoPlayer+MediaCodec视频解码Buffer模式支持,另一方面我们可以看到ExoPlayer高度的可扩展性,相比而言,非常适合Android开发者学习。

通过本篇我们了解MediaCodec、ExoPlayer一些模式,其实MediaCodec和Ffmpeg本质上是同一级别的多媒体框架,而ExoPlayer属于产品几遍了,后续我们实现下ExoPlayer+Ffmpeg视频解码,方便大家进一步对比MediaCodec。