OpenGL.ES在Android上的简单实践:21-水印录制(硬件编码 MediaCodec 上)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/a360940265a/article/details/80447547

OpenGL.ES在Android上的简单实践:21-水印录制(硬件编码 MediaCodec 上)

1、录制视频需要什么?

在上篇文章,我们已经成功的满足了需求,在预览摄像头的同时加上一些简单的视频二次处理(水印)。接下来我们就是要把视频录制下来,这就涉及视频的编码范畴了。视频编解码知识点无论在哪个平台上的操作系统上,都是比较难的一个知识点。在Android 4.1以前,Android并没有提供硬编硬解的API,所以之前基本上都是采用FFMpeg来做视频软件编解码的,现在FFMpeg在Android的编解码上依旧广泛应用。通常来说,对于同一平台同一硬件环境,硬编硬解的速度是快于软件编解码的。而且相比软件编解码的高CPU占用率来说,硬件编解码也有很大的优势,所以在硬件支持的情况下,一般硬件编解码是我们的首选。 本篇博客主要是利用Android4.1增加的API MediaCodec和Android 4.3增加的API MediaMuxer进行Mp4视频的录制。

https://developer.android.com/reference/android/media/MediaCodec

既然需要使用系统API进行硬编码录制视频,我们就从官方文档入手(上方连接,需要梯子)看看MediaCodec是怎么玩的。

官网上的图能够很好的说明MediaCodec的使用方式。我们从这两段英文入手理解MediaCodec:

MediaCodec类可用于访问低层媒体编解码器,即编码器/解码器组件。它是Android低层多媒体支持基础设施的一部分。(通常与MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, Surface, and AudioTrack.一起联合使用)

广义地说,编解码器处理输入数据以产生输出数据。它异步处理数据,并使用一组输入和输出缓冲区。在一个简单化的层次上,你请求(或接收)一个空的输入缓冲区,用数据填充它并将其发送到编解码器进行处理。编解码器使用的数据,并将其转换为其空输出缓冲区之一。最后,请求(或接收)填充的输出缓冲区,消耗其内容并将其释放回编解码器。

好了基本意思就是这样了,我们可以看到,工作原理比较简单,其中有几个关键字:异步(线程工作),编解码(不单指是流转文件,也可以文件转流,或者更实际的网络拉流直播显示),媒体数据(不单只是视频还可以音频)。

2、Let‘s Prepare Record.

废话不多,但我还是想要废话几句的,就是先看看官方的MediaCodec和MediaMuxer的Sample。我不怎么喜欢对API的介绍因为这些网上太多了,而且都一样,稍微有个理解认识就可以了。

public class CameraRecordEncoderCore {
    private static final String TAG = "CameraRecordEncoderCore";
    private static final boolean DEBUG = true;
    private static final int FRAME_RATE = 30;               // 30fps
    private static final int I_FRAME_INTERVAL = 5;          // I-frames 间隔 5s

    private MediaCodec mVideoEncoder;
    private Surface mInputSurface;
    private MediaMuxer mMuxer;

    /**
     * 配置 编码器和合成器的各种状态,准备输入源供外部喂养数据。
     * @param width 编码视频的宽度
     * @param height 编码视频的高度
     * @param bitRate 比特率/码率
     * @param outputFile 输出mp4路径
     */
    public CameraRecordEncoderCore(int width, int height, int bitRate, File outputFile)
            throws IOException {
        // 1. 设置编码器类型
        // MediaFormat.MIMETYPE_VIDEO_AVC = "video/avc"; // H.264 Advanced Video Coding
        MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height);
        format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL);
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT, //设置输入源类型为原生Surface 重点1 参考下面官网复制过来的说明
                COLOR_FormatSurface);
        //Raw Video Buffers
        // In ByteBuffer mode video buffers are laid out according to their color format.
        // You can get the supported color formats as an array from getCodecInfo().getCapabilitiesForType(…).colorFormats.
        // Video codecs may support three kinds of color formats:
        // I、native raw video format: This is marked by COLOR_FormatSurface and
        //      it can be used with an input or output Surface.
        // II、flexible YUV buffers (such as COLOR_FormatYUV420Flexible): These can be used with an input/output Surface,
        //      as well as in ByteBuffer mode, by using getInput/OutputImage(int).
        // III、other, specific formats: These are normally only supported in ByteBuffer mode.
        //      Some color formats are vendor specific. Others are defined in MediaCodecInfo.CodecCapabilities.
        //      For color formats that are equivalent to a flexible format, you can still use getInput/OutputImage(int).

        // 2. 创建我们的编码器,配置我们以上的设置
        mVideoEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
        mVideoEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        // 3. 获取编码喂养数据的输入源surface
        mInputSurface = mVideoEncoder.createInputSurface();
        mVideoEncoder.start();
        // 4. 创建混合器,但我们不能在这里start,因为我们还没有编码后的视频数据,
        // 更没有把编码后的数据以track(轨道)的形式加到合成器。
        mMuxer = new MediaMuxer(outputFile.toString(),
                MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    }

    public Surface getInputSurface() {
        return mInputSurface;
    }
    ... ... ...
}

我们开始跟着注释分析学习:

0、首先从CameraRecordEncoderCore命名上我们知道,这部分是摄像头录制编码的核心工作部分,但并不是工作的流程。大家别先入为主。(并不是这里控制录制视频,这只是录制视频中关键的工具部分)

1、按照官方说明,创建一个编码器我们需要配置编码格式等一系列参数,然后我们通过MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);获取一个AVC(H.264)的编码器,记得是Encoder,不是Decoder。别搞错了。随后我们看看编码器配置Codec.configure的四个参数:第一个MediaFormat,就是想要设置的格式,没啥大问题;第二个Surface,此Surface是在解码器的时候使用的,告诉解码后的视频渲染介质,用于系统直接渲染,提高性能效率;第三个MediaCrypto是媒体加解密,我们这里不用到,先忽略;第四个是指定其Codec是一个编码器。

2、成功创建编码器后,我们从编码器中获取一个InputSurface,这个和刚刚Codec.configure第二个参数OutputSurface相对应。既然Codec充当解码器的时候能指定渲染Surface,那么在Codec充当是编码器的时候,也要有一个输入Surface,在输入Surface渲染的画面就能直接当做输入源流进到编码器中,提高性能和效率。我们开启接口供外部引用这个InputSurface。

3、接着我们利用MediaMuxer创建MP4格式的视频混合器。关于MP4和上面说的AVC(H.264)这些概念如果有搞不清的同学,请(一定要)点击这里前辈大神总结的详细知识。基本概括就是:MP4等一些常见的视频文件,这些文件其实类似一个包裹,它的后缀则是包裹的包装方式。这些包裹里面,包含了视频(只有图像),音频(只有声音),字幕等。当播放器在播放的时候,首先对这个包裹进行拆包(专业术语叫做分离/splitting),把其中的视频、音频等拿出来,再进行播放。既然它们只是一个包裹,就意味着这个后缀不能保证里面的东西是啥,也不能保证到底有多少东西。包裹里面的每一件物品,我们称之为轨道(track)。每个轨道所承载的物件都经过特定的压缩格式(H.264)进行压缩。编码相当于这个压缩这个操作,压缩后的数据我们以轨道(track)为单位打包成MP4的文件,这个操作就是MediaMuxer混合器来完成的。

编码器我们已经准备好了,那么我继续看看应该怎么编码:

    private MediaCodec.BufferInfo mBufferInfo;
    private int mTrackIndex;
    private boolean mMuxerStarted;
    private static final int TIMEOUT_USEC = 10000;
    /**
     * 从编码器中提取所有未处理的数据,并将其转发给Muxer。
     * endOfStream是代表是否编码结束的终结符,
     * 如果是false就是正常请求输入数据去编码,按正常流程走这次编码操作。
     * 如果是true我们需要告诉编码器编码工作结束了,发送一个EOS结束标志位到输入源,
     * 然后等到我们在编码输出的数据发现EOS的时候,证明最后的一批编码数据已经编码成功了。
     */
    public void drainEncoder(boolean endOfStream) {

        if (endOfStream) {
            if (DEBUG) Log.d(TAG, "sending EOS to encoder");
            mVideoEncoder.signalEndOfInputStream();
        }
        // 1. 获取编码输出队列
        ByteBuffer[] encoderOutputBuffers = mVideoEncoder.getOutputBuffers();
        while (true) {
            // 2. 从编码的输出队列中检索出各种状态,对应处理。
            // 参数一是MediaCodec.BufferInfo,主要是用来承载对应buffer的附加信息。
            // 参数二是超时时间,请注意单位是微秒,1毫秒=1000微秒,这里设置10毫秒。
            int encoderStatus = mVideoEncoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
            if(encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
                // 暂时还没输出的数据能捕获
                if (!endOfStream) {
                    break;      // out of while(true){}
                } else {
                    if (DEBUG) Log.d(TAG, "no output available, spinning to await EOS");
                }
            } else if(encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                // 这个状态说明输出队列对象改变了,请重新获取一遍。
                encoderOutputBuffers = mVideoEncoder.getOutputBuffers();
            } else if(encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                // 当我们接收到编码后的输出数据,会通过格式已转变这个标志触发,而且只会发生一次格式转变
                // 因为不可能从设置指定的格式变成其他,难不成一个视频能有两种编码格式?
                if (mMuxerStarted) {
                    throw new RuntimeException("format changed twice");
                }
                MediaFormat videoFormat = mVideoEncoder.getOutputFormat();
                // 现在我们已经得到想要的编码数据了,让我们开始合成进mp4容器文件里面吧。
                mTrackIndex = mMuxer.addTrack(videoFormat);
                // 获取track轨道号,等下写入编码数据的时候需要用到
                mMuxer.start();
                mMuxerStarted = true;
            } else if(encoderStatus < 0) {
                Log.w(TAG, "unexpected result from encoder.dequeueOutputBuffer: " + encoderStatus);
                // Continue while(true)
            } else {
                // 3. 各种状态处理之后,大于0的encoderStatus则是指出了编码数据是在编码队列的具体位置。
                ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
                if (encodedData == null) {
                    throw new RuntimeException("encoderOutputBuffer " + encoderStatus + " was null");
                }
                if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
                    // 这表明,标记为这样的缓冲器包含编解码器初始化/编解码器特定数据而不是媒体数据。
                    if (DEBUG) Log.d(TAG, "ignoring BUFFER_FLAG_CODEC_CONFIG");
                    mBufferInfo.size = 0;
                }
                if (mBufferInfo.size != 0) {
                    if (!mMuxerStarted) {
                        throw new RuntimeException("muxer hasn't started");
                    }
                    // adjust the ByteBuffer values to match BufferInfo (not needed?)
                    encodedData.position(mBufferInfo.offset);
                    encodedData.limit(mBufferInfo.offset + mBufferInfo.size);
                    mMuxer.writeSampleData(mTrackIndex, encodedData, mBufferInfo);
                    if (DEBUG) {
                        Log.d(TAG, "sent " + mBufferInfo.size + " bytes to muxer, ts=" +
                                mBufferInfo.presentationTimeUs);
                    }
                }
                // 释放 编码器输出队列中 指定位置的buffer,第二个参数指定是否将其buffer渲染到解码Surface
                mVideoEncoder.releaseOutputBuffer(encoderStatus, false);

                if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                    if (!endOfStream) {
                        Log.w(TAG, "reached end of stream unexpectedly");
                    } else {
                        if (DEBUG) Log.d(TAG, "end of stream reached");
                    }
                    break;      // out of while
                }
            }
        }
    }

整个方法看着有点复杂,我们慢慢分析:

0、首先这个方法是主动调用的,并附带一个endOfStream的标志符,这个标志符在函数说明的注释已经说明白;如果是false就是正常请求输入数据去编码,按正常流程走这次编码操作。如果是true我们需要告诉编码器编码工作结束了,通过Codec.signalEndOfInputStream发送一个EOS结束标志位到输入源,然后等到我们在编码输出的数据发现EOS的时候,证明最后的一批编码数据已经编码成功了。

1、我们通过mInputSurface渲染(外部调用)画面之后,正常开始编码。先是获取编码输出队列的引用,是一个ByteBuffer的数组,然后我们通过dequeueOutputBuffer请求编码后的buffer出列,返回的是encoderStatus编码状态。根据编码状态我们逐一分析。

2、MediaCodec的编解码标志位有以下三个:
encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER:说明Codec暂时还没输出的数据能捕获,如果不是主动请求EOS结束的,我们可以跳过这次请求编码的申请。
encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:说明 输出队列对象改变了,请重新获取一遍。

encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:当我们接收到编码后的输出数据,会通过格式已转变这个标志触发,而且只会发生一次格式转变,因为不可能从设置指定的格式变成其他,难不成一个视频能有两种编码格式?此时我们就可以开始MP4合成器的工作了。

以上的encoderStatus都是定义成负值小于0的,当如果是encoderStatus大于0的,则是代表编码数据是在编码队列的具体位置,数组的索引值。通过数组索引我们获取特定的ByteBuffer,并检查这ByteBuffer的有效性。确定有效之后,我就根据MediaCodec.BufferInfo的信息调整这组编码后的ByteBuffer。最后我们以track为单位,写入MediaMuxer进行合成。

当我们写入MediaMuxer的数据成功后,我们不急着跳出while(true),因为Codec是异步操作的,我们只管喂养数据,和请求捕获结果。根据官方介绍,我们在捕获消耗数据后,应该将其是否回收到编码器中。通过Codec.releaseOutputBuffer(encoderStatus, false);释放编码器的输出队列中指定位置的buffer,第二个参数指定是否渲染其buffer到解码Surface,这个是Codec为解码器的时候才起作用。我们这里填写为false;

3、最后我们怎么结束编码 和 合成器呢?首先肯定是调用drainEncoder(true); 然后就是release回收资源了。代码如下:

public void release() {
        if (VERBOSE) Log.d(TAG, "releasing encoder objects");
        if (mVideoEncoder != null ) {
            mVideoEncoder.stop();
            mVideoEncoder.release();
            mVideoEncoder = null;
        }
        if (mMuxer != null && mTrackIndex != -1) {
            // stop() throws an exception if you haven't fed it any data.
            // Keep track of frames submitted, and don't call stop() if we haven't written anything.
            // Once the muxer stops, it can not be restarted.
            mMuxer.stop();
            mMuxer.release();
            mMuxer = null;
        }
    }

3、Let's Start Recording

所需的工具我们已经准备好了,下一步我们就要搞清怎么控制录制这个操作,和录制前我们需要什么。说回Codec中的InputSurface,既然有个输入Surface能直接把渲染画面流进Codec,我们为何不把这个Surface结合我们自己的EGL组成EGLSurface,然后按照之前预览帧那样渲染?  还有一点需要注意,录制的EGL环境 和 实时预览的EGL环境渲染是两个独立的工作环境,所以我们的录制是另外一个线程的工作的。 跟随这些思路,我们开始编写CameraRecordEncoder,大致的框架如下:

public class CameraRecordEncoder implements Runnable {
    private static final String TAG = "CameraRecordEncoder";

    /**
     * 编码器设置的bean,为啥不通过构造函数传递。
     * 因为通常情况下,构造的时候都还没清楚设置,和还没获取到EGLContext~2333
     */
    public static class EncoderConfig {
        final File mOutputFile;
        final int mWidth;
        final int mHeight;
        final int mBitRate;
        final EGLContext mEglContext;

        public EncoderConfig(File outputFile, int width, int height, int bitRate,
                             EGLContext sharedEglContext) {
            mOutputFile = outputFile;
            mWidth = width;
            mHeight = height;
            mBitRate = bitRate;
            mEglContext = sharedEglContext;
        }

        @Override
        public String toString() {
            return "EncoderConfig: " + mWidth + "x" + mHeight + " @" + mBitRate +
                    " to '" + mOutputFile.toString() + "' ctxt=" + mEglContext;
        }
    }

    // ----- 外部线程通信访问 -----
    private volatile EncoderHandler mHandler;
    private final Object mSyncLock = new Object();
    private boolean mReady;
    private boolean mRunning;

    /**
     * 利用handler机制处理外部线程请求编码器的操作。
     * 嫌弃自己搭建Thread+Handler麻烦的同学可以用 HandlerThread
     */
    class EncoderHandler extends Handler {
        private WeakReference<CameraRecordEncoder> mWeakEncoder;

        public EncoderHandler(CameraRecordEncoder encoder) {
            mWeakEncoder = new WeakReference<CameraRecordEncoder>(encoder);
        }

        @Override
        public void handleMessage(Message msg) {
            CameraRecordEncoder encoder = mWeakEncoder.get();
            if (encoder == null) {
                Log.w(TAG, "EncoderHandler.handleMessage: encoder is null");
                return;
            }
        }
    }

    /**
     * 开始视频录制。(一般是从其他非录制现场调用的)
     * 我们创建一个新线程,并且根据传入的录制配置EncoderConfig创建编码器。
     * 我们挂起线程等待正式启动后才返回。
     */
    public void startRecording(EncoderConfig encoderConfig) {
        Log.d(TAG, "CameraRecordEncoder: startRecording()");
        synchronized (mSyncLock) {
            if (mRunning) {
                Log.w(TAG, "Encoder thread already running");
                return;
            }
            mRunning = true;
            new Thread(this, "CameraRecordEncoder").start();
            while (!mReady) {
                try {
                    // 等待编码器线程的启动
                    mSyncLock.wait();
                } catch (InterruptedException ie) {
                    ie.printStackTrace();
                }
            }
        }
        //mHandler.sendMessage(
        //        mHandler.obtainMessage(EncoderHandler.MSG_START_RECORDING, encoderConfig) );
    }

    @Override
    public void run() {
        Looper.prepare();
        synchronized (mSyncLock) {
            mHandler = new EncoderHandler(this);
            mReady = true;
            mSyncLock.notify();
        }
        Looper.loop();

        Log.d(TAG, "Encoder thread exiting");
        synchronized (mSyncLock) {
            mReady = mRunning = false;
            mHandler = null;
        }
    }
}

注释都很清楚了,反正就是一个独立的工作线程+Handler机制,供外部访问请求编码器的控制。看不懂的先去补补Android的知识吧。 可以知道,CameraRecordEncoder的一切开始都是在startRecording这个方法。但是,我们现在暂且不去理会EncoderHandler.MSG_START_RECORDING的具体实现,反过来思考,我们在原有测试页面ContinuousRecordActivity的预览摄像头的代码上,要怎样处理录像这个操作。只有得到明确的需求,我们才能更好的去实现CameraRecordEncoder。 要不我们就模仿微信的长按录制?一个触碰的按钮,按下状态是请求开始录像(startRecording),手指抬起请求终结录像的录制(stopRecording)。还有在实时预览每一帧的同时(frameAvailable),渲染到我们的CameraRecordEncoder。

SO,这样分析,我们就至少需要三个供外部调用的接口了。startRecording / stopRecording / frameAvailable,现在我们就来编写其余两个,供外部线程访问录像渲染线程操作的方法。

public class CameraRecordEncoder implements Runnable {    
    ... ... ...
    public static class EncoderConfig { ... ... //follow github }
    // ---------------以下代码 供外部线程通信访问 ---------------------------------------------------------------------------
    private volatile EncoderHandler mHandler;
    private final Object mSyncLock = new Object();
    private boolean mReady;
    private boolean mRunning;

    /**
     * 开始视频录制。(一般是从其他非录制线程调用的)
     * 我们创建一个新线程,并且根据传入的录制配置EncoderConfig创建编码器。
     * 我们挂起线程等待正式启动后才返回。
     */
    public void startRecording(EncoderConfig encoderConfig) {
        Log.d(TAG, "CameraRecordEncoder: startRecording()");
        synchronized (mSyncLock) {
            if (mRunning) {
                Log.w(TAG, "Encoder thread already running");
                return;
            }
            mRunning = true;
            new Thread(this, "CameraRecordEncoder").start();
            while (!mReady) {
                try {
                    // 等待编码器线程的启动
                    mSyncLock.wait();
                } catch (InterruptedException ie) {
                    ie.printStackTrace();
                }
            }
        }
        mHandler.sendMessage(mHandler.obtainMessage(EncoderHandler.MSG_START_RECORDING, encoderConfig));
    }
    
    @Override
    public void run() {
        Looper.prepare();
        synchronized (mSyncLock) {
            mHandler = new EncoderHandler(this);
            mReady = true;
            mSyncLock.notify();
        }
        Looper.loop();

        Log.d(TAG, "Encoder thread exiting");
        synchronized (mSyncLock) {
            mReady = mRunning = false;
            mHandler = null;
        }
    }
    
    /**
     * 告诉录像渲染线程停止录像  (一般是从其他非录制线程调用的)
     */
    public void stopRecording() {
        mHandler.sendMessage(mHandler.obtainMessage(EncoderHandler.MSG_STOP_RECORDING));
        mHandler.sendMessage(mHandler.obtainMessage(EncoderHandler.MSG_QUIT));
        // Codec和Muxer感觉不是立刻结束的,我们是不是应该弄个回调?
    }

    public void frameAvailable(... ...) {
        synchronized (mSyncLock) {
            if (!mReady) {
                return;
            }
        }
        mHandler.sendMessage(mHandler.obtainMessage(EncoderHandler.MSG_FRAME_AVAILABLE));
    }

    // 利用handler机制处理外部线程请求编码器的操作。
    class EncoderHandler extends Handler {
        static final int MSG_START_RECORDING = 0;
        static final int MSG_STOP_RECORDING = 1;
        static final int MSG_QUIT = 2;
        static final int MSG_FRAME_AVAILABLE = 3;
        private WeakReference<CameraRecordEncoder> mWeakEncoder;

        public EncoderHandler(CameraRecordEncoder encoder) {
            mWeakEncoder = new WeakReference<CameraRecordEncoder>(encoder);
        }
        @Override
        public void handleMessage(Message msg) {
            CameraRecordEncoder encoder = mWeakEncoder.get();
            if (encoder == null) {
                Log.w(TAG, "EncoderHandler.handleMessage: encoder is null");
                return;
            }
            int what = msg.what;
            Object obj = msg.obj;
            switch (what) {
                case MSG_START_RECORDING:
                    encoder.handleStartRecording((CameraRecordEncoder.EncoderConfig) obj);
                    break;
                case MSG_STOP_RECORDING:
                    encoder.handleStopRecording(... ...);
                    break;
                case MSG_QUIT:
                    Looper.myLooper().quit();
                    // 不能直接在stopRecording中quit,因为调用stopRecording的looper不是我们想退出的线程looper。
                    break;
            }
        }
    }
    // ---------------以上代码 供外部线程通信访问 -------------------------------------------------------------------------
    ... ... ...
    ... ... ...
}

CameraRecordEncoder的代码量比较多,希望同学能分清楚其设计思路。这节已经show过两次的设计逻辑,下节我们着重处理外部请求的编码器的操作方法。 

The End .

by the way. 祝各位大小朋友儿童节快乐!

猜你喜欢

转载自blog.csdn.net/a360940265a/article/details/80447547