FFmpeg - 朋友圈录制视频添加背景音乐

前几天有同学问了个问题:辉哥,我们录制视频怎么添加背景音乐?就在今天群里也有哥们在问:Android 上传的视频 iOS 没法播放,我怎么转换格式呢?令我很惊讶的是大家似乎不会 FFmpeg 也没有音视频基础,但大家又在做一些关于音视频的功能。搞得我们好像三言两语施点法,就能帮大家解决问题似的。因此打算写下此篇文章,希望能帮到有需要的同学。


gif 录制有点卡

视频录制涉及到知识点还是挺多的,但如果大家不去细究原理与源码,只是把效果做出来还是挺简单的,首先我们来罗列一下大致的流程:

  1. OpenGL 预览相机
  2. MediaCodec 编码相机数据
  3. MediaMuxer 合成输出视频文件

1. OpenGL 预览相机

我们需要用到 OpenGL 来渲染相机和采集数据,当然我们也可以直接用 SurfaceView 来预览 Camera ,但直接用 SufaceView 并不方便美颜滤镜和加水印贴图,关于 OpenGL 的基础知识大家可以持续关注后期的文章。为了方便共享渲染同一个纹理,我们对 GLSurfaceView 的源码进行修改,但前提是大家需要对 GLSurfaceView 的源码以及渲染流程了如指掌,否则不建议大家直接去修改源码,因为不同的版本不同机型,会给我们造成不同的困扰。能在不修改源码的情况下能解决的问题,尽量不要去动源码,因此我们尽量用扩展的方式去实现。

/**
 * 扩展 GLSurfaceView ,暴露 EGLContext
 */
public class BaseGLSurfaceView extends GLSurfaceView {
    /**
     * EGL环境上下文
     */
    protected EGLContext mEglContext;

    public BaseGLSurfaceView(Context context) {
        this(context, null);
    }

    public BaseGLSurfaceView(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 利用 setEGLContextFactory 这种扩展方式把 EGLContext 暴露出去
        setEGLContextFactory(new EGLContextFactory() {
            @Override
            public EGLContext createContext(EGL10 egl, EGLDisplay display, EGLConfig eglConfig) {
                int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE};
                mEglContext = egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT, attrib_list);
                return mEglContext;
            }

            @Override
            public void destroyContext(EGL10 egl, EGLDisplay display, EGLContext context) {
                if (!egl.eglDestroyContext(display, context)) {
                    Log.e("BaseGLSurfaceView", "display:" + display + " context: " + context);
                }
            }
        });
    }

    /**
     * 通过此方法可以获取 EGL环境上下文,可用于共享渲染同一个纹理
     * @return EGLContext
     */
    public EGLContext getEglContext() {
        return mEglContext;
    }
}

顺便提醒一下,我们需要用扩展纹理属性,否则相机画面无法渲染出来,同时采用 FBO 离屏渲染来绘制,因为有些实际开发场景需要加一些水印或者是贴纸等等。

    @Override
    public void onDrawFrame(GL10 gl) {
        // 绑定 fbo
        mFboRender.onBindFbo();
        GLES20.glUseProgram(mProgram);
        mCameraSt.updateTexImage();

        // 设置正交投影参数
        GLES20.glUniformMatrix4fv(uMatrix, 1, false, matrix, 0);

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mVboId);
        /**
         * 设置坐标
         * 2:2个为一个点
         * GLES20.GL_FLOAT:float 类型
         * false:不做归一化
         * 8:步长是 8
         */
        GLES20.glEnableVertexAttribArray(vPosition);
        GLES20.glVertexAttribPointer(vPosition, 2, GLES20.GL_FLOAT, false, 8,
                0);
        GLES20.glEnableVertexAttribArray(fPosition);
        GLES20.glVertexAttribPointer(fPosition, 2, GLES20.GL_FLOAT, false, 8,
                mVertexCoordinate.length * 4);

        // 绘制到 fbo
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        // 解绑
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
        mFboRender.onUnbindFbo();
        // 再把 fbo 绘制到屏幕
        mFboRender.onDrawFrame();
    }

2. MediaCodec 编码相机数据

相机渲染显示后,接下来我们开一个线程去共享渲染相机的纹理,并且把数据绘制到 MediaCodec 的 InputSurface 上。

    /**
     * 视频录制的渲染线程
     */
    public static final class VideoRenderThread extends Thread {
        private WeakReference<BaseVideoRecorder> mVideoRecorderWr;
        private boolean mShouldExit = false;
        private boolean mHashCreateContext = false;
        private boolean mHashSurfaceChanged = false;
        private boolean mHashSurfaceCreated = false;
        private EglHelper mEGlHelper;
        private int mWidth;
        private int mHeight;

        public VideoRenderThread(WeakReference<BaseVideoRecorder> videoRecorderWr) {
            this.mVideoRecorderWr = videoRecorderWr;
            mEGlHelper = new EglHelper();
        }

        public void setSize(int width, int height) {
            this.mWidth = width;
            this.mHeight = height;
        }

        @Override
        public void run() {
            while (true) {
                if (mShouldExit) {
                    onDestroy();
                    return;
                }

                BaseVideoRecorder videoRecorder = mVideoRecorderWr.get();
                if (videoRecorder == null) {
                    mShouldExit = true;
                    continue;
                }

                if (!mHashCreateContext) {
                    // 初始化创建 EGL 环境
                    mEGlHelper.initCreateEgl(videoRecorder.mSurface, videoRecorder.mEglContext);
                    mHashCreateContext = true;
                }

                GL10 gl = (GL10) mEGlHelper.getEglContext().getGL();

                if (!mHashSurfaceCreated) {
                    // 回调 onSurfaceCreated
                    videoRecorder.mRenderer.onSurfaceCreated(gl, mEGlHelper.getEGLConfig());
                    mHashSurfaceCreated = true;
                }

                if (!mHashSurfaceChanged) {
                    // 回调 onSurfaceChanged
                    videoRecorder.mRenderer.onSurfaceChanged(gl, mWidth, mHeight);
                    mHashSurfaceChanged = true;
                }

                // 回调 onDrawFrame
                videoRecorder.mRenderer.onDrawFrame(gl);

                // 绘制到 MediaCodec 的 Surface 上面去
                mEGlHelper.swapBuffers();

                try {
                    // 60 fps
                    Thread.sleep(16 / 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

        private void onDestroy() {
            mEGlHelper.destroy();
        }

        public void requestExit() {
            mShouldExit = true;
        }
    }

3. MediaMuxer 合成输出视频文件

目前已有两个线程,一个线程是相机渲染到屏幕显示,一个线程是共享相机渲染纹理绘制到 MediaCodec 的 InputSurface 上。那么我们还需要一个线程用 MediaCodec 编码合成视频文件。

    /**
     * 视频的编码线程
     */
    public static final class VideoEncoderThread extends Thread {
        private WeakReference<BaseVideoRecorder> mVideoRecorderWr;

        private volatile boolean mShouldExit;

        private MediaCodec mVideoCodec;
        private MediaCodec.BufferInfo mBufferInfo;
        private MediaMuxer mMediaMuxer;

        /**
         * 视频轨道
         */
        private int mVideoTrackIndex = -1;

        private long mVideoPts = 0;

        public VideoEncoderThread(WeakReference<BaseVideoRecorder> videoRecorderWr) {
            this.mVideoRecorderWr = videoRecorderWr;
            mVideoCodec = videoRecorderWr.get().mVideoCodec;
            mBufferInfo = new MediaCodec.BufferInfo();
            mMediaMuxer = videoRecorderWr.get().mMediaMuxer;
        }

        @Override
        public void run() {
            mShouldExit = false;
            mVideoCodec.start();

            while (true) {
                if (mShouldExit) {
                    onDestroy();
                    return;
                }

                int outputBufferIndex = mVideoCodec.dequeueOutputBuffer(mBufferInfo, 0);

                if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                    mVideoTrackIndex = mMediaMuxer.addTrack(mVideoCodec.getOutputFormat());
                    mMediaMuxer.start();
                } else {
                    while (outputBufferIndex >= 0) {
                        // 获取数据
                        ByteBuffer outBuffer = mVideoCodec.getOutputBuffers()[outputBufferIndex];
                        outBuffer.position(mBufferInfo.offset);
                        outBuffer.limit(mBufferInfo.offset + mBufferInfo.size);

                        // 修改视频的 pts
                        if (mVideoPts == 0) {
                            mVideoPts = mBufferInfo.presentationTimeUs;
                        }
                        mBufferInfo.presentationTimeUs -= mVideoPts;

                        // 写入数据
                        mMediaMuxer.writeSampleData(mVideoTrackIndex, outBuffer, mBufferInfo);

                        // 回调当前录制时间
                        if (mVideoRecorderWr.get().mRecordInfoListener != null) {
                            mVideoRecorderWr.get().mRecordInfoListener.onTime(mBufferInfo.presentationTimeUs / 1000);
                        }

                        // 释放 OutputBuffer
                        mVideoCodec.releaseOutputBuffer(outputBufferIndex, false);
                        outputBufferIndex = mVideoCodec.dequeueOutputBuffer(mBufferInfo, 0);
                    }
                }
            }
        }

        private void onDestroy() {
            // 先释放 MediaCodec
            mVideoCodec.stop();
            mVideoCodec.release();
            // 后释放 MediaMuxer
            mMediaMuxer.stop();
            mMediaMuxer.release();
        }

        public void requestExit() {
            mShouldExit = true;
        }
    }

在不深究解编码协议的前提下,只是把效果写出来还是很简单的,但一出现问题往往就无法下手了,因此还是有必要去深究一些原理,了解一些最最基础的东西,敬请期待!

视频地址:https://pan.baidu.com/s/14EVKkIPkRbu8idb-1N-9jw
视频密码:jnbp

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

推荐阅读更多精彩内容