Android音视频——MediaCodec编码mp4踩坑记录

3,011

欢迎关注微信公众号:FSA全栈行动 👋

项目需要在低端 Android 设备上驱动相机获取 YUV 图像,同时,还需要进行录像,YUV 图像的获取与处理之前已经趟过去了,总体感觉只要掌握了相机与 YUV 原理等知识点后,结合 libyuv 这个牛逼的库基本就没什么了,而录像这一块则是使用 MediaCodec + MediaMuxer 来处理,本篇就是我在使用原生 MediaCodec 编码 mp4 文件的踩杭记要,主要有两个问题:

  • 录像变色 (video wrong color)
  • 录像时长缩水(play too fast)

注:低端的 Android 设备硬件条件有多差呢?大概就是 2014 年 Android4.x 手机那种水平吧,CPU 处理速度很感人,对此,唯有硬编码才是王道。

一、录像变色

在探究该问题前,先来了解一下 MediaCodec 的两种编码模式:

  • ByteBuffer 模式(手动档):
    • 格式:COLOR_FORMAT 对应的值是 MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar(图像格式 NV21)。
    • 操作:通过 MediaCodec.dequeueInputBuffer() 获取数据输入缓冲区,再通过 MediaCodec.queueInputBuffer() 手动将 YUV 图像传给 MediaCodec
  • Surface 模式(自动档):
    • 格式:COLOR_FORMAT 对应的值是 MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
    • 操作:通过 MediaCodec.createInputSurface() 创建编码数据源 Surface,再通过 OpenGL 纹理,将相机预览图像绘制到该 Surface 上。

1、现象

相机预览正常,但是录制出来的 mp4 视频颜色很阴间。

说明:就跟 YUV 图像把 u/v 颠倒之后的效果一样。

2、分析

ByteBuffer 模式 下,从相机处获取到原始的 NV21 图像,交给设置了 COLOR_FORMATCOLOR_FormatYUV420SemiPlanarMediaCodec,结果在不同的 Android 设备上,有的正常,有的不正常(少数),刚开始以为是个别设备上不支持这种 COLOR_FORMAT,但事实并非如此。stackoverflow 某个歪果仁对此问题的解释如下:

The YUV formats used by Camera output and MediaCodec input have their U/V planes swapped.

If you are able to move the data through a Surface you can avoid this issue; however, you lose the ability to examine the YUV data. An example of recording to a .mp4 file from a camera can be found on bigflake.

Some details about the colorspaces and how to swap them is in this answer.

...

注:stackoverflow 文章链接:stackoverflow.com/questions/1…

所以说,这是 MediaCodec 本身的 bug,它自己会对输入的 YUV 图像的 u/v 进行交换,解决的方案有 2 种:

  • 使用 ByteBuffer 模式,在把 NV21 图像传给 MediaCodec 之前,先把 NV21 转成 NV12(毕竟这俩货仅仅只是 u/v 相反而已),但前面已经提到了,只是少数设备会有这种情况,适配起来估计有够呛的。不推荐
  • 使用 Surface 模式,可以完美避免这种情况,但同时会丧失对原 YUV 图像的处理能力,不过可以使用 OpenGL 方式来处理图像。推荐

3、实现

大致步骤如下:

  • 一方面,使用 OpenGL 纹理创建纹理,并包装为 SurfaceTexture 给相机作为 preview 窗口,这样相机图像就会呈现在纹理上。
  • 另一方面,使用 mMediaCodec.createInputSurface() 作为 MediaCodec 的编码数据源。
  • 最后,在相机预览的同时,让纹理上的图像绘制到 inputSurface

说明:Camera ---> TextureId(OpenGL) ---> InputSurface(MediaCodec)

具体实现可以在 bigflake 的 Demo(CameraToMpegTest) 中获取:www.bigflake.com/mediacodec/…

二、录像时长缩水(丢帧)

解决该问题有两个关键:

  • 时间戳对齐:
    • ByteBuffer 模式:通过 MediaCodec.queueInputBuffer() 手动将 YUV 图像传给 MediaCodec 的同时,需要传递当前的时间戳,注意时间单位是微秒(us)。
    • Surface 模式:通过 MediaCodec.createInputSurface() 创建出来的 inputSurface,会有与之对比的 mEGLDisplay、mEGLSurface,在执行 EGL14.eglSwapBuffers(mEGLDisplay, mEGLSurface) 之前,通过 EGLExt.eglPresentationTimeANDROID(mEGLDisplay, mEGLSurface, nsecs) 对 MediaCodec 的 inputSurface 的数据设置时间戳。
  • 媒体格式配置:
    • MediaFormat 的关键帧间隔(KEY_I_FRAME_INTERVAL) 与 帧率(KEY_FRAME_RATE) 必须配置得当。

说明:这里是核心总结,可先跳过往下看,之后再回过头来看,会比较好理解。

1、现象

录制一段 10s 的视频,从设备上提取出来后,使用播放器播放观察。发现有的设备正常,个别设备录制出来的视频,时长仅仅只有一半,这也就是网上都在说的 play too fast 问题。

安利:OnlyStopWatch_x64.exe 这是一个计时器小工具,对于视频录制、直播这种需要观察时间快慢的场景很实用。画面丢失、播放太快等问题,都很容易看出来。

2、分析

前面提到的 stackoverflow 问答中,那个歪果仁同时也表达了他对 使用MediaCodec录制出来的视频播放太快 这个问题的解释:

...

There is no timestamp information in the raw H.264 elementary stream. You need to pass the timestamp through the decoder to MediaMuxer or whatever you're using to create your final output. If you don't, the player will just pick a rate, or possibly play the frames as fast as it can.

注:stackoverflow 文章链接:stackoverflow.com/questions/1…

他认为 H.264 不包含时间戳信息,你需要把时间戳通过编码器(MediaCodec)给到媒体复用器(MediaMuxer),否则,播放器会选择一个速率,尽快地播放帧。

3、实现

如果是 ByteBuffer 模式,则核心代码实现如下:

private void feedMediaCodecData(byte[] data) {
    if (!isEncoderStart)
        return;
    int bufferIndex = -1;
    try {
        bufferIndex = mMediaCodec.dequeueInputBuffer(0);
    } catch (IllegalStateException e) {
        e.printStackTrace();
    }
    if (bufferIndex >= 0) {
        ByteBuffer buffer = null;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            try {
                buffer = mMediaCodec.getInputBuffer(bufferIndex);
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            if (inputBuffers != null) {
                buffer = inputBuffers[bufferIndex];
            }
        }
        if (buffer != null) {
            buffer.clear();
            buffer.put(data);
            buffer.clear();
            // 纳秒(ns) 转 微秒(us)
            mMediaCodec.queueInputBuffer(bufferIndex, 0, data.length, System.nanoTime() / 1000, MediaCodec.BUFFER_FLAG_KEY_FRAME);
        }
    }
}

这时要注意,MediaCodec 需要的时间单位是微秒(us),如果你不使用正确的时间,可能会出问题,比如:stackoverflow.com/questions/2…

补充:秒(s)、毫秒(ms)、微秒(us)、纳秒(ns),之间的比例都是 1:1000。

如果是 Surface 模式,则核心代码实现如下:

// 更新纹理图像
// Acquire a new frame of input, and render it to the Surface.  If we had a GLSurfaceView we could switch EGL contexts and call drawImage() a second time to render it on screen.  The texture can be shared between contexts by passing the GLSurfaceView's EGLContext as eglCreateContext()'s share_context argument.
mSurfaceTexture.updateTexImage();
mSurfaceTexture.getTransformMatrix(mSTMatrix);

// 传入时间戳信息
// Set the presentation time stamp from the SurfaceTexture's time stamp.  This will be used by MediaMuxer to set the PTS in the video.
mInputSurface.setPresentationTime(mSurfaceTexture.getTimestamp());
// Submit it to the encoder.  The eglSwapBuffers call will block if the input is full, which would be bad if it stayed full until we dequeued an output buffer (which we can't do, since we're stuck here).  So long as we fully drain the encoder before supplying additional input, the system guarantees that we can supply another frame without blocking.
mInputSurface.swapBuffers();

具体实现可以在 bigflake 的 Demo(CameraToMpegTest) 中获取:www.bigflake.com/mediacodec/…

4、修补

尽管按照上面的步骤,把时间戳正确传递给 MediaMuxer 了,但依旧无济于事。经过将我项目中的代码与 bigflake 的 CameraToMpegTest 中的代码进行对比,发现,MediaFormat 的配置上也很关键,必须配置得当,否则也还是会出现时长缩水的问题,于是我将原来项目中的代码进行修改,将帧率修改为 30f,关键帧间距改为 5s,丢帧的问题这才解决了,MediaFormat 配置具体代码如下:

protected static final String MIME_TYPE = "video/avc";
protected static final int FRAME_INTERVAL = 5; // 间隔5s插入一帧关键帧
protected static final int FRAME_RATE = 30;
protected static final float BPP = 0.50f;
protected int mColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;

private void initMediaCodec(){
    final MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, mWidth, mHeight);
    format.setInteger(MediaFormat.KEY_COLOR_FORMAT, mColorFormat);
    format.setInteger(MediaFormat.KEY_BIT_RATE, calcBitRate());
    format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
    format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, FRAME_INTERVAL);

    try {
        mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
    } catch (IOException e) {
        e.printStackTrace();
    }
    mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    // get Surface for encoder input
    // this method only can call between #configure and #start
    // API >= 18
    mSurface = mMediaCodec.createInputSurface();
    mMediaCodec.start();
}

protected int calcBitRate() {
    final int bitrate = (int) (BPP * FRAME_RATE * mWidth * mHeight);
    Log.i(TAG, String.format("bitrate=%5.2f[Mbps]", bitrate / 1024f / 1024f));
    return bitrate;
}

另外,亲测只要 MediaFormat 配置没问题,就算时间戳不传递也没影响,emmm...,既然时间戳的代码已经写好了,又暂时没出现其他坑,为了安全起见,还是把时间戳信息带上吧。

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有Android技术, 还有iOS, Python等文章, 可能有你想要了解的技能知识点哦~