MediaMuxer+MediaCodec生成MP4视频黑屏

发表这篇文章目的是为了记录一次解决Android开发中遇到的问题,总结解决思路及心得.这里要特别感谢指导我的刘老师,新项目的领导.

现象:配置(CPU)稍微偏低的手机生成视频播放时为黑屏.
初步分析:为写入视频时出错导致.
分析的思路如下:

下面是音视频混合代码:
EncoderVideoRunnable和MediaMuxerRunnable是两个线程,前者生成编码后的视频数据,后者将视频数据写入文件.

(AiMediaMuxer.java)
private class MediaMuxerRunnable implements Runnable {

        @Override
        public void run() {
            initMuxer();
            baseTimeStamp = System.nanoTime();
            while (!isExit) {
                // 混合器没有启动或数据缓存为空,则阻塞混合线程等待启动(数据输入)
                if (isMuxerStarted) {
                    // 从缓存读取数据写入混合器中
                    if (mMuxerDatas.isEmpty()) {
//                        PaDebugUtil.i(TAG, "run--->混合器没有数据,阻塞线程等待");
                        synchronized (lock) {
                            try {
                                lock.wait();
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                        }
                    } else {
                        MuxerData data = mMuxerDatas.remove(0);
                        if (data != null) {
                            int track = 0;
                            try {
                                if (data.trackIndex == TRACK_VIDEO) {
                                    track = videoTrack;
//                                    PaDebugUtil.d(TAG, "---写入视频数据---");
                                } else if (data.trackIndex == TRACK_AUDIO) {
//                                    PaDebugUtil.d(TAG, "---写入音频数据---");
                                    track = audioTrack;
                                }
//                                PaDebugUtil.d(TAG, "before SampleData presentationTimeUs: "+data.bufferInfo.presentationTimeUs);
                                mMuxer.writeSampleData(track, data.byteBuf, data.bufferInfo);
                                prevOutputPTSUs = data.bufferInfo.presentationTimeUs;
                            } catch (Exception e) {
                                PaDebugUtil.e(TAG, "写入数据到混合器失败,track=" + track);
                                e.printStackTrace();
                            }
                        }
                    }
                } else {
                    PaDebugUtil.i(TAG, "run--->混合器没有启动,阻塞线程等待");
                    synchronized (lock) {
                        try {
                            lock.wait();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
            stopMuxer();
        }
    }

其中mMuxerDatas为自定义混合器数据集合,便于MediaMuxer.writeSampleData()使用.

private Vector<MuxerData> mMuxerDatas;
/**
    * 封装要混合器数据实体
     */
public static class MuxerData {
        int trackIndex;
        ByteBuffer byteBuf;
        MediaCodec.BufferInfo bufferInfo;

        public MuxerData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo) {
            this.trackIndex = trackIndex;
            this.byteBuf = byteBuf;
            this.bufferInfo = bufferInfo;
        }
    }

组装数据的地方:

(EncoderVideoRunnable.java)
@SuppressLint("NewApi")
    private void encoderBytes(byte[] rawFrame) {

        ByteBuffer[] inputBuffers = mVideoEncodec.getInputBuffers();
        ByteBuffer[] outputBuffers = mVideoEncodec.getOutputBuffers();

        //返回编码器的一个输入缓存区句柄,-1表示当前没有可用的输入缓存区
        int inputBufferIndex = mVideoEncodec.dequeueInputBuffer(TIMES_OUT);
        if (inputBufferIndex >= 0) {
            // 绑定一个被空的、可写的输入缓存区inputBuffer到客户端
            ByteBuffer inputBuffer = null;
            if (!isLollipop()) {
                inputBuffer = inputBuffers[inputBufferIndex];
            } else {
                inputBuffer = mVideoEncodec.getInputBuffer(inputBufferIndex);
            }
            // 向输入缓存区写入有效原始数据,并提交到编码器中进行编码处理
            inputBuffer.clear();
            inputBuffer.put(rawFrame);
            mVideoEncodec.queueInputBuffer(inputBufferIndex, 0, rawFrame.length, getPTSUs(), 0);
        }

        // 返回一个输出缓存区句柄,当为-1时表示当前没有可用的输出缓存区
        // mBufferInfo参数包含被编码好的数据,timesOut参数为超时等待的时间
        int outputBufferIndex = -1;
        MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
        do {
            outputBufferIndex = mVideoEncodec.dequeueOutputBuffer(mBufferInfo, TIMES_OUT);
            if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
//        PaDebugUtil.i(TAG, "获得编码器输出缓存区超时");
            } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                // 如果API小于21,APP需要重新绑定编码器的输入缓存区;
                // 如果API大于21,则无需处理INFO_OUTPUT_BUFFERS_CHANGED
                if (!isLollipop()) {
                    outputBuffers = mVideoEncodec.getOutputBuffers();
                }
            } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                // 编码器输出缓存区格式改变,通常在存储数据之前且只会改变一次
                // 这里设置混合器视频轨道,如果音频已经添加则启动混合器(保证音视频同步)
                MediaFormat newFormat = mVideoEncodec.getOutputFormat();
                AiMediaMuxer mMuxerUtils = muxerRunnableRf.get();
                if (mMuxerUtils != null) {
                    mMuxerUtils.setMediaFormat(AiMediaMuxer.TRACK_VIDEO, newFormat);
                    PaDebugUtil.i(TAG, "编码器输出缓存区格式改变,添加视频轨道到混合器");
                }
            } else {
                // 获取一个只读的输出缓存区inputBuffer ,它包含被编码好的数据
                ByteBuffer outputBuffer = null;
                if (!isLollipop()) {
                    outputBuffer = outputBuffers[outputBufferIndex];
                } else {
                    outputBuffer = mVideoEncodec.getOutputBuffer(outputBufferIndex);
                }
                // 如果API<=19,需要根据BufferInfo的offset偏移量调整ByteBuffer的位置
                // 并且限定将要读取缓存区数据的长度,否则输出数据会混乱
                if (isKITKAT()) {
                    outputBuffer.position(mBufferInfo.offset);
                    outputBuffer.limit(mBufferInfo.offset + mBufferInfo.size);
                }
                // 根据NALU类型判断帧类型
                AiMediaMuxer mMuxerUtils = muxerRunnableRf.get();
                int type = outputBuffer.get(4) & 0x1F;
//        PaDebugUtil.d(TAG, "------还有数据---->" + type);
                if (type == 7 || type == 8) {
//          PaDebugUtil.e(TAG, "------PPS、SPS帧(非图像数据),忽略-------");
                    mBufferInfo.size = 0;
                } else if (type == 5) {
                    // 录像时,第1秒画面会静止,这是由于音视轨没有完全被添加
                    // Muxer没有启动
//          PaDebugUtil.e(TAG, "------I帧(关键帧)-------");
                    if (mMuxerUtils != null && mMuxerUtils.isMuxerStarted()) {
//            mBufferInfo.presentationTimeUs = getPTSUs();
                        mMuxerUtils.addPreviewData(
                                new AiMediaMuxer.MuxerData(AiMediaMuxer.TRACK_VIDEO, outputBuffer, mBufferInfo));
                        prevPresentationTimes = mBufferInfo.presentationTimeUs;
                        isAddKeyFrame = true;
//            PaDebugUtil.e(TAG, "----------->添加关键帧到混合器");
                    }
                } else {
                    if (isAddKeyFrame) {
//            PaDebugUtil.d(TAG, "------非I帧(type=1),添加到混合器-------");
                        if (mMuxerUtils != null && mMuxerUtils.isMuxerStarted()) {
//              mBufferInfo.presentationTimeUs = getPTSUs();
                            mMuxerUtils.addPreviewData(
                                    new AiMediaMuxer.MuxerData(AiMediaMuxer.TRACK_VIDEO, outputBuffer, mBufferInfo));
                            prevPresentationTimes = mBufferInfo.presentationTimeUs;
//              PaDebugUtil.d(TAG, "------添加到混合器");
                        }
                    }
                }
                // 处理结束,释放输出缓存区资源
                mVideoEncodec.releaseOutputBuffer(outputBufferIndex, false);

                outputBuffer = null;
//        outputBuffers = null;
//        System.gc();
            }
        } while (outputBufferIndex >= 0);
    }

录制过程中,我们发现黑屏的视频在MediaMuxer.writeSampleData()方法中catch到了异常:
MediaAdapter: "pushBuffer called before start"
我们找到MediaAdapter源码(http://androidxref.com/7.0.0_r1/xref/frameworks/av/media/libstagefright/MediaAdapter.cpp)抛出异常的地方:

status_t MediaAdapter::pushBuffer(MediaBuffer *buffer) {
    if (buffer == NULL) {
        ALOGE("pushBuffer get an NULL buffer");
        return -EINVAL;
    }

    Mutex::Autolock autoLock(mAdapterLock);
    if (!mStarted) {
        ALOGE("pushBuffer called before start");
        return INVALID_OPERATION;
    }
    mCurrentMediaBuffer = buffer;
    mBufferReadCond.signal();

    ALOGV("wait for the buffer returned @ pushBuffer! %p", buffer);
    mBufferReturnedCond.wait(mAdapterLock);

    return OK;
}

这里写明是mStarted = false的时候会抛出异常,往上查找到是调用了stop()方法后才置为false,那这里可以猜想到肯定是其他地方调用了stop()方法才导致的,那什么情况下会调用stop呢?
我们继续看到adb日志里有一条:
MPEG4Writer:"do not support out of order frames (timestamp: 1892312322 < 1892312350"
我们找到MPEG4Writer源码(http://androidxref.com/7.0.0_r1/xref/frameworks/av/media/libstagefright/MPEG4Writer.cpp)抛出异常的地方:

currDurationTicks =
    ((timestampUs * mTimeScale + 500000LL) / 1000000LL -
        (lastTimestampUs * mTimeScale + 500000LL) / 1000000LL);
if (currDurationTicks < 0ll) {
    ALOGE("do not support out of order frames (timestamp: %lld < last: %lld for %s track",
            (long long)timestampUs, (long long)lastTimestampUs, trackName);
    copy->release();
    mSource->stop();
    return UNKNOWN_ERROR;
}

通过阅读源码,我们发现这个时间戳应该是底层写入视频数据时的时间戳,即我们在writeSampleData()方法中传入的BufferInfo的presentationTimeUs的值做了一些换算.
我们实现视频数据写入的逻辑中看到,EncoderVideoRunnable线程负责将编码好的视频数据交给MediaMuxerRunnable线程写入文件.初步分析应该是BufferInfo的presentationTimeUs在什么地方被修改了,然后我们在writeSampleData和new MediaCodec.BufferInfo()这两个地方都打印了BufferInfo的内存地址和presentationTimeUs,然后发现在写入视频信息的时候BufferInfo的presentationTimeUs并不是上一次写入的时间戳,
这里插入一段逻辑:

// 向MediaMuxer添加录屏数据
    public void addPreviewData(MuxerData data) {
        if (needAddKeyPreviewData && (data.byteBuf.get(4) & 0x1F) != 5) {
            return;
        }
        needAddKeyPreviewData = false;

        if (isStopWriteDate || isReacordingScreen) {
            return;
        }
        if (mMuxerDatas == null) {
            PaDebugUtil.e(TAG, "添加数据失败");
            return;
        }
        data.bufferInfo.presentationTimeUs = getPTSUs();
        mMuxerDatas.add(data);
        // 解锁
        synchronized (lock) {
            lock.notify();
        }
    }

/**
     * 获取下一个编码的 presentationTimeUs
     * @return
     */
    public  long getPTSUs() {
        //long result = System.nanoTime() / 1000L;
        long result = System.nanoTime();
        // presentationTimeUs should be monotonic
        // otherwise muxer fail to write
        long time = (result - pauseDelayTime) / 1000;

        if (time < prevOutputPTSUs){
            return  prevOutputPTSUs;
        }

        return time;
    }

会判断一次当前时间戳与上一次写入视频信息的时间戳做一个比较取最大值,因而prevOutputPTSUs不可能比上一次小,那么问题就出在当前presentationTimeUs在赋值正确的时间戳后去写入视频信息的时候,这个presentationTimeUs被更改了,这里的BufferInfo对象其实是在EncoderVideoRunnable中创建的,当EncoderVideoRunnable中dequeueOutputBuffer的时候会被更改.
常规CPU运行情况下,这种几率几乎可以忽略不计,但是少数性能稍微差的手机就会大概率出现这种情况了.
这个时候只需要在dequeueOutputBuffer的时候,每次都创建一个新的BufferInfo对象,这样就不会影响写入的时候BufferInfo的presentationTimeUs被修改了.
修改后的EncoderVideoRunnable代码:

do {
            MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
            outputBufferIndex = mVideoEncodec.dequeueOutputBuffer(mBufferInfo, TIMES_OUT);
            if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
//        PaDebugUtil.i(TAG, "获得编码器输出缓存区超时");
            } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                // 如果API小于21,APP需要重新绑定编码器的输入缓存区;
                // 如果API大于21,则无需处理INFO_OUTPUT_BUFFERS_CHANGED
                if (!isLollipop()) {
                    outputBuffers = mVideoEncodec.getOutputBuffers();
                }
            } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                // 编码器输出缓存区格式改变,通常在存储数据之前且只会改变一次
                // 这里设置混合器视频轨道,如果音频已经添加则启动混合器(保证音视频同步)
                MediaFormat newFormat = mVideoEncodec.getOutputFormat();
                AiMediaMuxer mMuxerUtils = muxerRunnableRf.get();
                if (mMuxerUtils != null) {
                    mMuxerUtils.setMediaFormat(AiMediaMuxer.TRACK_VIDEO, newFormat);
                    PaDebugUtil.i(TAG, "编码器输出缓存区格式改变,添加视频轨道到混合器");
                }
            } else {
                // 获取一个只读的输出缓存区inputBuffer ,它包含被编码好的数据
                ByteBuffer outputBuffer = null;
                if (!isLollipop()) {
                    outputBuffer = outputBuffers[outputBufferIndex];
                } else {
                    outputBuffer = mVideoEncodec.getOutputBuffer(outputBufferIndex);
                }
                // 如果API<=19,需要根据BufferInfo的offset偏移量调整ByteBuffer的位置
                // 并且限定将要读取缓存区数据的长度,否则输出数据会混乱
                if (isKITKAT()) {
                    outputBuffer.position(mBufferInfo.offset);
                    outputBuffer.limit(mBufferInfo.offset + mBufferInfo.size);
                }
                // 根据NALU类型判断帧类型
                AiMediaMuxer mMuxerUtils = muxerRunnableRf.get();
                int type = outputBuffer.get(4) & 0x1F;
//        PaDebugUtil.d(TAG, "------还有数据---->" + type);
                if (type == 7 || type == 8) {
//          PaDebugUtil.e(TAG, "------PPS、SPS帧(非图像数据),忽略-------");
                    mBufferInfo.size = 0;
                } else if (type == 5) {
                    // 录像时,第1秒画面会静止,这是由于音视轨没有完全被添加
                    // Muxer没有启动
//          PaDebugUtil.e(TAG, "------I帧(关键帧)-------");
                    if (mMuxerUtils != null && mMuxerUtils.isMuxerStarted()) {
//            mBufferInfo.presentationTimeUs = getPTSUs();
                        mMuxerUtils.addPreviewData(
                                new AiMediaMuxer.MuxerData(AiMediaMuxer.TRACK_VIDEO, outputBuffer, mBufferInfo));
                        prevPresentationTimes = mBufferInfo.presentationTimeUs;
                        isAddKeyFrame = true;
//            PaDebugUtil.e(TAG, "----------->添加关键帧到混合器");
                    }
                } else {
                    if (isAddKeyFrame) {
//            PaDebugUtil.d(TAG, "------非I帧(type=1),添加到混合器-------");
                        if (mMuxerUtils != null && mMuxerUtils.isMuxerStarted()) {
//              mBufferInfo.presentationTimeUs = getPTSUs();
                            mMuxerUtils.addPreviewData(
                                    new AiMediaMuxer.MuxerData(AiMediaMuxer.TRACK_VIDEO, outputBuffer, mBufferInfo));
                            prevPresentationTimes = mBufferInfo.presentationTimeUs;
//              PaDebugUtil.d(TAG, "------添加到混合器");
                        }
                    }
                }
                // 处理结束,释放输出缓存区资源
                mVideoEncodec.releaseOutputBuffer(outputBufferIndex, false);

                outputBuffer = null;
//        outputBuffers = null;
//        System.gc();
            }
        } while (outputBufferIndex >= 0);

其实只是在dequeueOutputBuffer前每次都创建新的BufferInfo.
改完运行,发现问题解决了,呼呼...

最后,总结一下从发现问题到解决问题的全过程:
1,遇到问题不要觉得太难还没开始就放弃思考,如果最后没有解决问题,但是分析思路的养成也是非常重要.
2,尽量多分析源码,对解决问题事半功倍.
3,代码大忌生搬硬套,网上大手也有写bug的情况,代码抄过来要分析每一步的逻辑,养成好的编码习惯.

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