Android音视频编码录制mp4

Android录制视频有多种方法:MediaRecorder, MediaProjection, MediaMuxer, OpenGL等,每种方法都有其应用场景。

这里介绍的是用MediaCodec + MediaMuxer录制视频,这种方式是将音频流和视频流用MediaCodec编码,然后用MediaMuxer混流合成mp4视频, 这种方式的通用性较好,它不关心数据来源,只要能获得音视频流数据,就能录制。

音视频开发自有完整体系,其中的知识点和注意点(坑)也很多。网上的文章和开源项目虽不少,有些点却鲜有提及。这里将我开发中遇到的问题和一些小结记录下来,如有错误,还请指教。

1. 数据源

视频来源选择Camera2,给Camera2添加ImageReader,就能获得实时的图像数据。具体操作可以看这篇文章

给ImageReader设置的输出格式是ImageFormat.YUV_420_888,这种格式是官方建议通用性最好的,但是它只能保证输出是YUV420格式,而YUV420分很多种,具体格式不同设备是不同的,可能是YUV420P,也可能是YUV420SP,后面编码还要考虑这个问题。

从ImageReader中获取byte[]数据方法如下

    /**
     * 从ImageReader中获取byte[]数据
     */
    public static byte[] getBytesFromImageReader(ImageReader imageReader) {
        try (Image image = imageReader.acquireNextImage()) {
            final Image.Plane[] planes = image.getPlanes();
            ByteBuffer b0 = planes[0].getBuffer();
            ByteBuffer b1 = planes[1].getBuffer();
            ByteBuffer b2 = planes[2].getBuffer();
            int y = b0.remaining(), u = y >> 2, v = u;
            byte[] bytes = new byte[y + u + v];
            if(b1.remaining() > u) { // y420sp
                b0.get(bytes, 0, b0.remaining());
                b1.get(bytes, y, b1.remaining()); // uv
            } else { // y420p
                b0.get(bytes, 0, b0.remaining());
                b1.get(bytes, y, b1.remaining()); // u
                b2.get(bytes, y + u, b2.remaining()); // v
            }
            return bytes;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

从ImageReader中取得Image,YUV数据就在Image的Plane[]中,Plane也就是平面有3个。Plane[0]是Y平面,数据量等于图像的像素个数,用plane.getBuffer().remaining()方法获得。
YUV420P格式下Plane[1]是U平面,数据量是Y/4,Plane[2]是V平面,数据量也是Y/4。
YUV420SP格式下Plane[1]和Plane[2]都是UV平面,它们数据基本相同,只是位置错开了一位。一般情况下用Plane[1]中的数据即可,Plane[1]的数据量可能是Y/2,也可能是Y/2 - 1,虽然差那一位数据在显示上没有影响,但会导致创建的数组大小不对。

音频来源选择AudioRecord,配置参数,启动,然后从单独线程中用AudioRecord.read()方法不断地循环读取数据。

2. 编码格式

视频编码用MediaCodec,根据MIME_TYPE = "video/avc"选择设备支持的编码器和colorFormat

    private MediaCodecInfo selectSupportCodec(String mimeType) {
        int numCodecs = MediaCodecList.getCodecCount();
        for (int i = 0; i < numCodecs; i++) {
            MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
            if (!codecInfo.isEncoder()) {
                continue;
            }
            String[] types = codecInfo.getSupportedTypes();
            for (int j = 0; j < types.length; j++) {
                if (types[j].equalsIgnoreCase(mimeType)) {
                    return codecInfo;
                }
            }
        }
        return null;
    }

    /**
     * 根据mime类型匹配编码器支持的颜色格式
     */
    private int selectSupportColorFormat(MediaCodecInfo mCodecInfo, String mimeType) {
        MediaCodecInfo.CodecCapabilities capabilities = mCodecInfo.getCapabilitiesForType(mimeType);
        HashSet<Integer> colorFormats = new HashSet<>();
        for(int i : capabilities.colorFormats) colorFormats.add(i);
        if(colorFormats.contains(MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar)) return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
        if(colorFormats.contains(MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar)) return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar;
        return 0;
    }

colorFormat颜色格式很重要,它代表了编码器接受的图像格式。输入其他格式的图像,会导致编码失败或者视频的图像颜色错乱。
目前来说:大部分手机都支持COLOR_FormatYUV420SemiPlanar格式(实测是NV12),因此首选这个格式;少数手机(如红米Note4,魅族MX5)不支持COLOR_FormatYUV420SemiPlanar,而支持COLOR_FormatYUV420Planar格式(实测是I420);这两种格式基本能覆盖所有的设备了。

前面说过Camera2设置ImageFormat.YUV_420_888后输出的图像格式是YUV420P或YUV420SP,实测格式是I420和NV12,它们跟视频编码器的支持的格式吻合,可以直接提供给视频编码器,当然一般还需要旋转。如果你的图像不是从Camera2获取的,或者是其他格式,就要将图像转换成编码器支持的格式。
(根据我的测试,如果手机支持COLOR_FormatYUV420SemiPlanar编码格式,那么它的Camera2相机输出的就是NV12格式;如果手机只支持COLOR_FormatYUV420Planar编码格式,那么它的Camera2相机输出的就是I420格式。因此我猜测Camera2输出用的就是相同的硬件视频编码器。)

音频编码也用MediaCodec,根据MIME_TYPE = "audio/mp4a-latm"选择编码器即可,基本没有设备差异问题。

3. 合成视频

主要步骤

  1. 用MediaCodec分别开始编码图像和声音
  2. 将编码时获得的图像和声音的MediaFormat添加到MediaMuxer
  3. 启动MediaMuxer将编码后的图像和声音合成mp4

3.1 编码

编码器输入byte[]原始数据,编码后输出ByteBuffer数据。
编码比较耗时,需要工作在单独的线程中。
编码的输入和输出是异步的,也就是输入数据,然后用循环不停获取输出。
编码一段时间后才能获取到MediaCodec.INFO_OUTPUT_FORMAT_CHANGED信息,这代表输出格式确定了,也就能向MediaMuxer添加轨道了。

视频编码器输入和输出如下

    // 视频编码器输入
    private void feedMediaCodecData(byte[] data, long timeStamp) {
        int inputBufferIndex = mVideoEncodec.dequeueInputBuffer(TIMES_OUT);
        if (inputBufferIndex >= 0) {
            ByteBuffer inputBuffer = mVideoEncodec.getInputBuffer(inputBufferIndex);
            if (inputBuffer != null) {
                inputBuffer.clear();
                inputBuffer.put(data);
            }
            Log.e("chao", "video set pts......." + (timeStamp) / 1000 / 1000);
            mVideoEncodec.queueInputBuffer(inputBufferIndex, 0, data.length, System.nanoTime() / 1000
                    , MediaCodec.BUFFER_FLAG_KEY_FRAME);
        }
    }

    // 视频编码器输出
                MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
                int outputBufferIndex;
                do {
                    outputBufferIndex = mVideoEncodec.dequeueOutputBuffer(mBufferInfo, TIMES_OUT);
                    if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
//                        Log.i(TAG, "INFO_TRY_AGAIN_LATER");
                    } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                        synchronized (H264EncodeConsumer.this) {
                            newFormat = mVideoEncodec.getOutputFormat();
                            if (mMuxerRef != null) {
                                MediaMuxerUtil muxer = mMuxerRef.get();
                                if (muxer != null) {
                                    muxer.addTrack(newFormat, true);
                                }
                            }
                        }

                        Log.i(TAG, "编码器输出缓存区格式改变,添加视频轨道到混合器");
                    } else {
                        ByteBuffer outputBuffer = mVideoEncodec.getOutputBuffer(outputBufferIndex);
                        int type = outputBuffer.get(4) & 0x1F;

                        Log.d(TAG, "------还有数据---->" + type);
                        if (type == 7 || type == 8) {

                            Log.e(TAG, "------PPS、SPS帧(非图像数据),忽略-------");
                            mBufferInfo.size = 0;
                        } else if (type == 5) {
                            if (mMuxerRef != null) {
                                MediaMuxerUtil muxer = mMuxerRef.get();
                                if (muxer != null) {
                                    Log.i(TAG, "------编码混合  视频关键帧数据-----" + mBufferInfo.presentationTimeUs / 1000);
                                    muxer.pumpStream(outputBuffer, mBufferInfo, true);
                                }
                                isAddKeyFrame = true;
                            }
                        } else {
                            if (isAddKeyFrame) {
                                if (isAddKeyFrame && mMuxerRef != null) {
                                    MediaMuxerUtil muxer = mMuxerRef.get();
                                    if (muxer != null) {
                                        Log.i(TAG, "------编码混合  视频普通帧数据-----" + mBufferInfo.presentationTimeUs / 1000);
                                        muxer.pumpStream(outputBuffer, mBufferInfo, true);
                                    }
                                }
                            }
                        }
                        mVideoEncodec.releaseOutputBuffer(outputBufferIndex, false);
                    }
                } while (outputBufferIndex >= 0);

音频编码器工作是类似的,由于我用的AudioRecord获取声音数据,编码时会有一个问题:

AudioRecord获取音频数据是用死循环不断获取的,这样获取声音的速度太快,编码又是耗时的,就会造成编码速度赶不上声音获取速度,也就是生产速度远大于消费速度,导致大部分数据都处理不完,视频中声音出问题。
音频编码器能设置MediaFormat.KEY_MAX_INPUT_SIZE,也就是输入数据包的大小。我采取的方法是给它设置一个较大的值,获取到声音数据后先缓存起来,拼接成较大的数据包后再提供给编码器,这样就能处理过来了。

3.2 合成

视频合成使用MediaMuxer合成器,用addTrack()方法添加视频轨道和声音轨道后才能启动,启动后用writeSampleData()方法输入数据后,直接输出到指定的mp4文件中。

完整代码

https://github.com/rome753/android-encode-mp4

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • 一、文章说明 最近工作实在太忙,很久没有更新文章了,收到很多小伙伴催更的消息,心中实在惭愧,趁着今天有空赶紧更新。...
    风从影阅读 18,751评论 33 118
  • 打包 视音频在传输过程中需要定义相应的格式,这样传输到对端的时候才能正确地被解析出来。 1、HTTP-FLV We...
    韩瞅瞅阅读 1,611评论 2 5
  • 本文讲的是谈论关于Android视频编码的那些坑,Android的视频相关的开发,大概一直是整个Android生态...
    福later阅读 4,143评论 0 7
  • 白云,一层层的白云,波浪般的白云。 我的脑海里,大多美好的回忆,都和它有关。 这是学校的云,也是现在的云。谈不上喜...
    端二小姐阅读 211评论 0 0
  • 一般情况下,大学四年里,有三样东西会一直保持不变,陪伴在你的身边,无论你对它们是爱,还是恨。 这三样东西就是你的专...
    大学SoWhat阅读 1,328评论 0 23