录制视频,速度录制

前言:

  • 在前面的文章中给小伙伴们介绍了进行相机拍照加入滤镜效果,如果你还没有看过的话,建议先去看上一篇文章 短视频编辑开发之相机拍照(四)
  • 本篇文章会介绍如何实现视频录制及视频变速录制

原理:

    视频录制说白了就是把一帧一帧的图片写到一个文件里,当然这里涉及到视频编码,如果不编码的话,你可以想象一秒钟如果有60帧图像那就是对应了60张图片一张720x1280的png图片有 大约有180k左右,那么60张就是10.54兆左右,要是录制一分钟就有600多兆了,所以视频就迫切的需要进行编码压缩了。这里就比较常用视频编码协议就是H264(常用),h265(压缩比高性能好,目前使用不是很广),在android的多媒体框架中MediaCodec是Android提供的用于对音视频进行编解码的类,它通过访问底层的codec来实现编解码的功能。是Android media基础框架的一部分,通常和 MediaExtractor, MediaSync, MediaMuxer, MediaCrypto, MediaDrm, Image, SurfaceAudioTrack 一起使用。这里编解码的工作我们就直接交给MediaCodec去做了,所以录制:就是采集到图像数据经过处理(滤镜,美颜,贴纸)后交给MediaCodec就可以了

变速录制:在录制的基础上控制视频帧的时间戳以达到播放时快播和慢播的效果,简单来说快就是“丢”帧,慢速录制就是“加”帧,但帧率都保持不变,变的是时长。比如我4秒的视频,帧率是20帧/秒,那一共是80帧,把每一帧都编码0,1,2…,78,79,假设我定义的快速即为2倍变速,即4秒最后变成2秒的视频,视频帧的变化就是丢弃掉一半的帧,只取0, 2, 4…76, 78合成2秒的视频,帧率依然是20帧/秒。慢速录制也以1/2速度为例,不过慢速录制相对复杂些,毕竟删除总是比创建容易,4秒的视频最终要变成8秒的视频,帧率不变,所以肯定要“加”帧,其实就是复制帧,依然是0,1,2…78,79的视频,对每一帧复制一遍,重新编码,最后编程0,0A,1,1A….78,78A,79,79A一共160帧的8秒视频。这其中最最核心的点在哪里?三个字:时间戳。快速录制的时候,你需要把正常第2n的时间戳设置为n, 慢速录制的时候,需要把时间戳为n的帧变成2n。

实现思路:

在前面文章的基础上我们学会了怎么拍照加美颜,在这个基础上我们使用Surface来完成视频录制,这里我们先看如何实现视频录制

具体实现

首先在《Android 短视频编辑开发之摄像头预览实时美颜(三)》这篇文章中我们有了数据渲染预览的Egl环境也就是每一帧绘制的地方,就在


drawFrame()这个方法里


这里是录制视频的入口也是处理每帧数据的地方

 

/**
     * 绘制帧
     */
    void drawFrame() {
    
        ...

        // 当记录的请求帧数不为时,更新画面
        while (mFrameNum > 0) {
            // 切换渲染上下文
            mDisplaySurface.makeCurrent();
            mSurfaceTexture.updateTexImage();
            mSurfaceTexture.getTransformMatrix(mMatrix);
            --mFrameNum;

            // 绘制渲染
            mCurrentTexture = mRenderManager.drawFrame(mInputTexture, mMatrix);

            // 显示到屏幕
            mDisplaySurface.swapBuffers();


            // 录制方法调用
           RecordControl.getInstance().onRecordFrameAvailable(mCurrentTexture,mSurfaceTexture.getTimestamp());
        }
    }

这里把当前绘制的textureId和当前TimeStamp传递给录制模块处理,最终调用到了这个方法。由于录制视频是非常消耗性能的所以在异步线程里去完成真|正的工作

/**
     * 录制帧可用状态
     * @param texture
     * @param timestamp
     */
    public void frameAvailable(int texture, long timestamp) {
        synchronized (mReadyFence) {
            if (!mReady) {
                return;
            }
        }
        // 时间戳为0时,不可用
        if (timestamp == 0) {
            return;
        }

        if (mHandler != null) {
            mHandler.sendMessage(mHandler.obtainMessage(MSG_FRAME_AVAILABLE,
                    (int) (timestamp >> 32), (int) timestamp, texture));
        }
    }

收到消息后VideoRecorder处理这里讲一下有个知识点OpenGL—多线程渲染共享上下文(share context)共享可以在两个线程中共享同一资源,在这里用了两个独立的线程一个是预览线程绘制界面展示到屏幕,一个是录制线程只执行录制工作。两个线程的好处就是能把预览和录制隔离,减轻预览的压力避免出现预览卡顿。所以我们在录制的时候建立了录制的Egl环境,在预览帧过来后,录制线程通过textureId可以拿到预览渲染的画面然后进行编解码

/**
     * 录制帧可用
     * @param texture
     * @param timestampNanos
     */
    private void onRecordFrameAvailable(int texture, long timestampNanos) {
        if (VERBOSE) {
            Log.d(TAG, "onRecordFrameAvailable");
        }
        if (mVideoEncoder == null) {
            return;
        }
  
        drawFrame(texture, timestampNanos);
        mDrawFrameIndex++;
    }

   /**
     * 绘制编码一帧数据
     * @param texture
     * @param timestampNanos
     */
    private void drawFrame(int texture, long timestampNanos) {
        mInputWindowSurface.makeCurrent();
        mImageFilter.drawFrame(texture, mVertexBuffer, mTextureBuffer);
        //设置帧的时间戳控制播放速度和顺序
        mInputWindowSurface.setPresentationTime(getPTS(timestampNanos));
        mInputWindowSurface.swapBuffers();
        mVideoEncoder.drainEncoder(false);
    }

这里贴一下录制线程初始化Egl环境的代码

private void onStartRecord(@NonNull VideoParams params) {
        if (VERBOSE) {
            Log.d(TAG, "onStartRecord " + params);
        }
        mVertexBuffer = OpenGLUtils.createFloatBuffer(TextureRotationUtils.CubeVertices);
        mTextureBuffer = OpenGLUtils.createFloatBuffer(TextureRotationUtils.TextureVertices);
        try {
            mVideoEncoder = new VideoEncoder(params, this);
        } catch (IOException ioe) {
            throw new RuntimeException(ioe);
        }
        // 创建EGL上下文和Surface
        mEglCore = new EglCore(params.getEglContext(), EglCore.FLAG_RECORDABLE);
        mInputWindowSurface = new WindowSurface(mEglCore, mVideoEncoder.getInputSurface(), true);
        mInputWindowSurface.makeCurrent();
        // 创建录制用的滤镜
        mImageFilter = new GLImageFilter(null);
        mImageFilter.onInputSizeChanged(params.getVideoWidth(), params.getVideoHeight());
        mImageFilter.onDisplaySizeChanged(params.getVideoWidth(), params.getVideoHeight());
        // 录制开始回调
        if (mRecordListener != null) {
            mRecordListener.onRecordStart(MediaType.VIDEO);
        }
    }

这里创建了InputSurface给了当前的Egl环境,也就是后面Egl绘制的数据都会绘制到这个inputSurface

mInputWindowSurface = new WindowSurface(mEglCore, mVideoEncoder.getInputSurface(), true);
 mInputWindowSurface.makeCurrent();

———————————————————————————————————————————————————————————————————————————————————————————

  mInputWindowSurface.swapBuffers();
  mVideoEncoder.drainEncoder(false);

下面就是绘制数据到inputSurface中去了,而这个inputSurface其实就是mMediaCodec创建的


mInputSurface = mMediaCodec.createInputSurface();


 

当有数据绘制提交到InputSurface后,MediaCodec就可以进行编码了,下面就是最终经过MediaCodec编码后的数据交由MediaMuxer去写到mp4文件中,完成了视频的录制。这里的MediaMuxer点击就能看到官方文档的介绍,这里不再赘述。

/**
     * 编码一帧数据到复用器中
     * @param endOfStream
     */
    public void drainEncoder(boolean endOfStream) {
        final int TIMEOUT_USEC = 10000;
        if (VERBOSE) {
            Log.d(TAG, "drainEncoder(" + endOfStream + ")");
        }

        if (endOfStream) {
            if (VERBOSE) Log.d(TAG, "sending EOS to encoder");
            mMediaCodec.signalEndOfInputStream();
        }

        ByteBuffer[] encoderOutputBuffers = mMediaCodec.getOutputBuffers();
        while (true) {
            int encoderStatus = mMediaCodec.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
            if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
                if (!endOfStream) {
                    break;      // out of while
                } else {
                    if (VERBOSE) Log.d(TAG, "no output available, spinning to await EOS");
                }
            } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                encoderOutputBuffers = mMediaCodec.getOutputBuffers();
            } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                if (mMuxerStarted) {
                    throw new RuntimeException("format changed twice");
                }
                MediaFormat newFormat = mMediaCodec.getOutputFormat();
                if (VERBOSE) {
                    Log.d(TAG, "encoder output format changed: " + newFormat.getString(MediaFormat.KEY_MIME));
                }
                // 提取视频轨道并打开复用器
                mTrackIndex = mMediaMuxer.addTrack(newFormat);
                mMediaMuxer.start();
                mMuxerStarted = true;
            } else if (encoderStatus < 0) {
                Log.w(TAG, "unexpected result from encoder.dequeueOutputBuffer: " +
                        encoderStatus);
            } else {
                ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
                if (encodedData == null) {
                    throw new RuntimeException("encoderOutputBuffer " + encoderStatus +
                            " was null");
                }

                if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
                    if (VERBOSE) {
                        Log.d(TAG, "ignoring BUFFER_FLAG_CODEC_CONFIG");
                    }
                    mBufferInfo.size = 0;
                }

                if (mBufferInfo.size != 0) {
                    if (!mMuxerStarted) {
                        throw new RuntimeException("muxer hasn't started");
                    }

                    // 计算录制时钟
                    calculateTimeUs(mBufferInfo);
                    // adjust the ByteBuffer values to match BufferInfo (not needed?)
                    encodedData.position(mBufferInfo.offset);
                    encodedData.limit(mBufferInfo.offset + mBufferInfo.size);
                    // 将编码数据写入复用器中
                    mMediaMuxer.writeSampleData(mTrackIndex, encodedData, mBufferInfo);
                    if (VERBOSE) {
                        Log.d(TAG, "sent " + mBufferInfo.size + " bytes to muxer, ts=" +
                                mBufferInfo.presentationTimeUs);
                    }

                    // 录制时长回调
                    if (mRecordingListener != null) {
                        mRecordingListener.onEncoding(mDuration);
                    }

                }

                mMediaCodec.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 (VERBOSE) {
                            Log.d(TAG, "end of stream reached");
                        }
                    }
                    break;      // out of while
                }
            }
        }
    }

到这里录制就结束了,那么这里录制的工作流程现在都走完了,如果我们要加入变速的逻辑,那可以在哪里加呢?

其实很简单我们只要在VideoRecorder类中


onRecordFrameAvailable(int texture, long timestampNanos)


录制帧的这个方法里控制下drawFrame的频率就可以了

如下代码

首先设置速度挡位

public enum SpeedMode {
        MODE_EXTRA_SLOW(1, 1/3f),  // 极慢
        MODE_SLOW(2, 0.5f),         // 慢
        MODE_NORMAL(3, 1.0f),       // 标准
        MODE_FAST(4, 2.0f),         // 快
        MODE_EXTRA_FAST(5, 3.0f);   // 极快

        private int type;
        private float speed;

        SpeedMode(int type, float speed) {
            this.type = type;
            this.speed = speed;
        }

    public int getType() {
        return type;
    }

    public float getSpeed() {
        return speed;
    }
}

然后

/**
     * 录制帧可用
     * @param texture
     * @param timestampNanos
     */
    private void onRecordFrameAvailable(int texture, long timestampNanos) {
        if (VERBOSE) {
            Log.d(TAG, "onRecordFrameAvailable");
        }
        if (mVideoEncoder == null) {
            return;
        }
        SpeedMode mode = mVideoEncoder.getVideoParams().getSpeedMode();
        // 快速录制的时候,需要做丢帧处理
        if (mode == SpeedMode.MODE_FAST || mode == SpeedMode.MODE_EXTRA_FAST) {
            int interval = 2;
            if (mode == SpeedMode.MODE_EXTRA_FAST) {
                interval = 3;
            }
            if (mDrawFrameIndex % interval == 0) {
                drawFrame(texture, timestampNanos);
            }
        } else {
            drawFrame(texture, timestampNanos);
        }
        mDrawFrameIndex++;
    }

在这里做快速录制丢帧的处理


mInputWindowSurface.setPresentationTime(getPTS(timestampNanos));


/**
     * 计算时间戳
     * @return
     */
    private long getPTS(long timestameNanos) {
        SpeedMode mode = mVideoEncoder.getVideoParams().getSpeedMode();
        if (mode == SpeedMode.MODE_NORMAL) { // 正常录制的时候,使用SurfaceTexture传递过来的时间戳
            return timestameNanos;
        } else { // 倍速状态下,需要根据帧间间隔来算实际的时间戳
            long time = System.nanoTime();
            if (mFirstTime <= 0) {
                mFirstTime = time;
            }
            return (long) (mFirstTime + (time - mFirstTime) / mode.getSpeed());
        }
    }

在getPTS方法里完成变速时时间戳的转换,以达到变速的目的。是不是很简单。

如有不当之处还请指正。