Android硬编码特效视频Surface+Camera2的结束

前语

本文并非专业音视频领域的文章,只不过是其在 Android 方向的 Camera 硬件下结合一些常用的应用场景罢了。

所以本文并不涉及到太专业的音视频知识,你只需求稍微了解一些以下知识点即可流畅阅览。

  1. Android 三种 Camera 分别怎么预览,有什么区别?
  2. 三种 Camera 回调的数据 byte[] 格局有什么区别?怎么转化怎么旋转?
  3. 录制视频中常用的 NV21,I420,Surface 三种输入格局对哪一种COLOR_FORMAT结束编码?
  4. 怎么装备 MediaCodec 的根本装备,帧率,分辨率,比特率,关键I帧的概念是否大致清楚。

了解这些之后,咱们基于体系的录制 API 已经能够根本结束对应的自界说录制流程了。假如不是很了解,也能够参阅看看我之前的文章或源代码,都有对应的示例。

这儿再着重一句,假如仅仅需求结束简略的视频录制,完全用 CameraX 的录制用例就够了,真没必要折腾。

话接前文,既然 Camerax 中的 VideoCapture 这么好,那么能够经过它的录制视频方法在 Camera1 或 Camera2 上结束吗?

答案是否定的,它继承 CameraX 的用例,只能用于 CameraX , 但是咱们能够把它的核心录制代码扒出来,自己结束一个录制视频的录制器。

有同学可能就说了,这不是脱裤子放屁,多此一举。这和直接用CameraX有什么区别?

Android录制视频,硬编实现特效视频的录制(一)

额,其实也不然,因为咱们终究是为了特效视频的录制直出,所以这些都是前置技术。

话不多说,咱们边走边说。

Android录制视频,硬编实现特效视频的录制(一)

一、仿VideoCapture结束完整录制

前文的咱们代码中,咱们用到异步回调的方法,与同步的方法来进行自界说的 MediaCodec 编码,假如是异步的方法,咱们增加了完毕标志符就能正确的结束录制,问题倒是不大。

而咱们运用同步的方法来进行录制,它的中止是由事件触发的,成果便是当即中止,导致正在编码的数据终究丢掉了,成果便是咱们录制的10秒的视频成果之后8秒。

而最好的解决计划应该是,收到中止录制的信号,设置一个flag,让编码器继续履行直到轨道中没有数据了才算录制结束,此刻再经过一个回调的方法暴露终究的录制地址,这样才是比较好的作用。

也便是咱们需求模仿 VideoCapture 结束的录制作用。

首先咱们对输出的文件目标做封装,能够是多种类型的格局,一般咱们运用的 File 的输出。

Android录制视频,硬编实现特效视频的录制(一)

Android录制视频,硬编实现特效视频的录制(一)

然后咱们就能界说一个回调,当录制真实结束的时候咱们回调出去,这儿依据机型的性能决议的,假如高端机型编译很快,根本上是同步结束的,假如是低端机型编码速度比较慢,就会等候1秒左右结束终究的录制。

Android录制视频,硬编实现特效视频的录制(一)

然后咱们再对音视频的录制的一些参数做一个封装,这儿能够界说一些默认的参数:

Android录制视频,硬编实现特效视频的录制(一)

而且运用构建者形式的方法构建:

Android录制视频,硬编实现特效视频的录制(一)

核心代码来咯,主线思路:

  1. 初始化东西类的时候,创立音频编码的线程 HandlerThread 与 视频编码的线程 HandlerThread。一起装备音频编码的装备与视频编码的装备。
  2. 当发动录制的时候,发动音频编码与视频编码,发动音频录制器,创立封装合成器。并经过各自的子线程开端编码。
  3. 音频编码器中正常写入同步时间戳之后,当结束编码发送给封装合成器去写入。
  4. 视频编码器中正常写入同步时间戳之后,当结束编码发送给封装合成器去写入。
  5. 在视频编码中经过写入中止录制的信号,判别当时是否需求真实结束录制,而且回调出去成果。

初始化东西类,创立并开启线程:

  public VideoCaptureUtils(@NonNull RecordConfig config, Size size) {
        this.mRecordConfig = config;
        this.mResolutionSize = size;
        // 初始化音视频编码线程
        mVideoHandlerThread = new HandlerThread(CameraXThreads.TAG + "video encoding thread");
        mAudioHandlerThread = new HandlerThread(CameraXThreads.TAG + "audio encoding thread");
        // 发动视频线程
        mVideoHandlerThread.start();
        mVideoHandler = new Handler(mVideoHandlerThread.getLooper());
        // 发动音频线程
        mAudioHandlerThread.start();
        mAudioHandler = new Handler(mAudioHandlerThread.getLooper());
        if (mCameraSurface != null) {
            mVideoEncoder.stop();
            mVideoEncoder.release();
            mAudioEncoder.stop();
            mAudioEncoder.release();
            releaseCameraSurface(false);
        }
        try {
            mVideoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE);
            mAudioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE);
        } catch (IOException e) {
            throw new IllegalStateException("Unable to create MediaCodec due to: " + e.getCause());
        }
        //设置音视频编码器与音频录制器
        setupEncoder();
    }

设置音视频编码器与音频录制器:

  void setupEncoder() {
        // 初始化视频编码器
        mVideoEncoder.reset();
        mVideoEncoder.configure(createVideoMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        if (mCameraSurface != null) {
            releaseCameraSurface(false);
        }
        //用于输入的Surface
        mCameraSurface = mVideoEncoder.createInputSurface();
        //初始化音频编码器
        mAudioEncoder.reset();
        mAudioEncoder.configure(createAudioMediaFormat(), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        //初始化音频录制器
        if (mAudioRecorder != null) {
            mAudioRecorder.release();
        }
        mAudioRecorder = autoConfigAudioRecordSource();
        if (mAudioRecorder == null) {
            Log.e(TAG, "AudioRecord object cannot initialized correctly!");
        }
        //重置音视频轨道,设置未开端录制
        mVideoTrackIndex = -1;
        mAudioTrackIndex = -1;
        mIsRecording = false;
    }

发动录制的时候,发动音视频编码器,发动音频录制器,创立封装合成器:

    public void startRecording(
            @NonNull OutputFileOptions outputFileOptions,
            @NonNull Executor executor,
            @NonNull OnVideoSavedCallback callback) {
        if (Looper.getMainLooper() != Looper.myLooper()) {
            CameraXExecutors.mainThreadExecutor().execute(() -> startRecording(outputFileOptions, executor, callback));
            return;
        }
        Log.d(TAG, "startRecording");
        mIsFirstVideoSampleWrite.set(false);
        mIsFirstAudioSampleWrite.set(false);
        VideoSavedListenerWrapper postListener = new VideoSavedListenerWrapper(executor, callback);
        //重复录制的过错
        if (!mEndOfAudioVideoSignal.get()) {
            postListener.onError(ERROR_RECORDING_IN_PROGRESS, "It is still in video recording!", null);
            return;
        }
        try {
            // 发动音频录制器
            mAudioRecorder.startRecording();
        } catch (IllegalStateException e) {
            postListener.onError(ERROR_ENCODER, "AudioRecorder start fail", e);
            return;
        }
        try {
            // 音视频编码器发动
            Log.d(TAG, "audioEncoder and videoEncoder all start");
            mVideoEncoder.start();
            mAudioEncoder.start();
        } catch (IllegalStateException e) {
            postListener.onError(ERROR_ENCODER, "Audio/Video encoder start fail", e);
            return;
        }
        //发动封装器
        try {
            synchronized (mMediaMuxerLock) {
                mMediaMuxer = initMediaMuxer(outputFileOptions);
                Preconditions.checkNotNull(mMediaMuxer);
                mMediaMuxer.setOrientationHint(90); //设置视频文件的方向,参数表明视频文件应该被旋转的角度
                Metadata metadata = outputFileOptions.getMetadata();
                if (metadata != null && metadata.location != null) {
                    mMediaMuxer.setLocation(
                            (float) metadata.location.getLatitude(),
                            (float) metadata.location.getLongitude());
                }
            }
        } catch (IOException e) {
            postListener.onError(ERROR_MUXER, "MediaMuxer creation failed!", e);
            return;
        }
        //设置开端录制的Flag变量
        mEndOfVideoStreamSignal.set(false);
        mEndOfAudioStreamSignal.set(false);
        mEndOfAudioVideoSignal.set(false);
        mIsRecording = true;
        //子线程开启编码音频
        mAudioHandler.post(() -> audioEncode(postListener));
        //子线程开启编码视频
        mVideoHandler.post(() -> {
            boolean errorOccurred = videoEncode(postListener);
            if (!errorOccurred) {
                postListener.onVideoSaved(new OutputFileResults(mSavedVideoUri));
                mSavedVideoUri = null;
            }
        });
    }

音频的详细编码:

    /**
     * 详细的音频编码方法,子线程中履行编码逻辑,无限履行知道录制完毕。
     * 当编码结束之后写入到缓冲区
     */
    boolean audioEncode(OnVideoSavedCallback videoSavedCallback) {
        // Audio encoding loop. Exits on end of stream.
        boolean audioEos = false;
        int outIndex;
        while (!audioEos && mIsRecording && mAudioEncoder != null) {
            // Check for end of stream from main thread
            if (mEndOfAudioStreamSignal.get()) {
                mEndOfAudioStreamSignal.set(false);
                mIsRecording = false;
            }
            // get audio deque input buffer
            if (mAudioEncoder != null) {
                int index = mAudioEncoder.dequeueInputBuffer(-1);
                if (index >= 0) {
                    final ByteBuffer buffer = getInputBuffer(mAudioEncoder, index);
                    buffer.clear();
                    int length = mAudioRecorder.read(buffer, mAudioBufferSize);
                    if (length > 0) {
                        mAudioEncoder.queueInputBuffer(
                                index,
                                0,
                                length,
                                (System.nanoTime() / 1000),
                                mIsRecording ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                    }
                }
                // start to dequeue audio output buffer
                do {
                    outIndex = mAudioEncoder.dequeueOutputBuffer(mAudioBufferInfo, 0);
                    switch (outIndex) {
                        case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
                            synchronized (mMediaMuxerLock) {
                                mAudioTrackIndex = mMediaMuxer.addTrack(mAudioEncoder.getOutputFormat());
                                Log.d(TAG, "mAudioTrackIndex:" + mAudioTrackIndex + "mVideoTrackIndex:" + mVideoTrackIndex);
                                if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0) {
                                    mMuxerStarted = true;
                                    Log.d(TAG, "media mMuxer start by audio");
                                    mMediaMuxer.start();
                                }
                            }
                            break;
                        case MediaCodec.INFO_TRY_AGAIN_LATER:
                            break;
                        default:
                            audioEos = writeAudioEncodedBuffer(outIndex);
                    }
                } while (outIndex >= 0 && !audioEos);
            }
        }
        //当循环完毕,阐明中止录制了,中止音频录制器
        try {
            Log.d(TAG, "audioRecorder stop");
            mAudioRecorder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Audio recorder stop failed!", e);
        }
        //中止音频编码器
        try {
            mAudioEncoder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Audio encoder stop failed!", e);
        }
        Log.d(TAG, "Audio encode thread end");
        mEndOfVideoStreamSignal.set(true);
        return false;
    }

音频数据写入封装合成器中:

    /**
     * 将已编码《音频流》写入缓冲区
     */
    private boolean writeAudioEncodedBuffer(int bufferIndex) {
        ByteBuffer buffer = getOutputBuffer(mAudioEncoder, bufferIndex);
        buffer.position(mAudioBufferInfo.offset);
        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0
                && mAudioBufferInfo.size > 0
                && mAudioBufferInfo.presentationTimeUs > 0) {
            try {
                synchronized (mMediaMuxerLock) {
                    if (!mIsFirstAudioSampleWrite.get()) {
                        Log.d(TAG, "First audio sample written.");
                        mIsFirstAudioSampleWrite.set(true);
                    }
                    mMediaMuxer.writeSampleData(mAudioTrackIndex, buffer, mAudioBufferInfo);
                }
            } catch (Exception e) {
                Log.e(TAG, "audio error:size="
                        + mAudioBufferInfo.size
                        + "/offset="
                        + mAudioBufferInfo.offset
                        + "/timeUs="
                        + mAudioBufferInfo.presentationTimeUs);
                e.printStackTrace();
            }
        }
        mAudioEncoder.releaseOutputBuffer(bufferIndex, false);
        return (mAudioBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
    }

接下来便是视频数据的编码,内部包含一些信号Flag的装备,先设置音频信号中止,然后内部设置了视频信号的中止,当视频中止之后,中止了封装合成器,并把信号与变量都重置。

    /**
     * 详细的视频编码方法,子线程中履行编码逻辑,无限履行知道录制完毕。
     * 当编码结束之后写入到缓冲区
     */
    boolean videoEncode(@NonNull OnVideoSavedCallback videoSavedCallback) {
        // Main encoding loop. Exits on end of stream.
        boolean errorOccurred = false;
        boolean videoEos = false;
        while (!videoEos && !errorOccurred && mVideoEncoder != null) {
            // Check for end of stream from main thread
            if (mEndOfVideoStreamSignal.get()) {
                mVideoEncoder.signalEndOfInputStream();
                mEndOfVideoStreamSignal.set(false);
            }
            // Deque buffer to check for processing step
            int outputBufferId = mVideoEncoder.dequeueOutputBuffer(mVideoBufferInfo, DEQUE_TIMEOUT_USEC);
            switch (outputBufferId) {
                case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
                    if (mMuxerStarted) {
                        videoSavedCallback.onError(ERROR_ENCODER, "Unexpected change in video encoding format.", null);
                        errorOccurred = true;
                    }
                    synchronized (mMediaMuxerLock) {
                        mVideoTrackIndex = mMediaMuxer.addTrack(mVideoEncoder.getOutputFormat());
                        Log.d(TAG, "mAudioTrackIndex:" + mAudioTrackIndex + "mVideoTrackIndex:" + mVideoTrackIndex);
                        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0) {
                            mMuxerStarted = true;
                            Log.i(TAG, "media mMuxer start by video");
                            mMediaMuxer.start();
                        }
                    }
                    break;
                case MediaCodec.INFO_TRY_AGAIN_LATER:
                    // Timed out. Just wait until next attempt to deque.
                    break;
                default:
                    videoEos = writeVideoEncodedBuffer(outputBufferId);
            }
        }
        //假如循环完毕,阐明录制结束,中止视频编码器,释放资源
        try {
            Log.i(TAG, "videoEncoder stop");
            mVideoEncoder.stop();
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_ENCODER, "Video encoder stop failed!", e);
            errorOccurred = true;
        }
        //因为视频编码会更耗时,所以在此中止封装器的履行
        try {
            synchronized (mMediaMuxerLock) {
                if (mMediaMuxer != null) {
                    if (mMuxerStarted) {
                        mMediaMuxer.stop();
                    }
                    mMediaMuxer.release();
                    mMediaMuxer = null;
                }
            }
        } catch (IllegalStateException e) {
            videoSavedCallback.onError(ERROR_MUXER, "Muxer stop failed!", e);
            errorOccurred = true;
        }
        if (mParcelFileDescriptor != null) {
            try {
                mParcelFileDescriptor.close();
                mParcelFileDescriptor = null;
            } catch (IOException e) {
                videoSavedCallback.onError(ERROR_MUXER, "File descriptor close failed!", e);
                errorOccurred = true;
            }
        }
        //设置一些Flag为中止状况
        mMuxerStarted = false;
        mEndOfAudioVideoSignal.set(true);
        Log.d(TAG, "Video encode thread end.");
        return errorOccurred;
    }

已编码的数据写入到封装合成器:

    /**
     * 将已编码的《视频流》写入缓冲区
     */
    private boolean writeVideoEncodedBuffer(int bufferIndex) {
        if (bufferIndex < 0) {
            Log.e(TAG, "Output buffer should not have negative index: " + bufferIndex);
            return false;
        }
        // Get data from buffer
        ByteBuffer outputBuffer = mVideoEncoder.getOutputBuffer(bufferIndex);
        // Check if buffer is valid, if not then return
        if (outputBuffer == null) {
            Log.d(TAG, "OutputBuffer was null.");
            return false;
        }
        // Write data to mMuxer if available
        if (mAudioTrackIndex >= 0 && mVideoTrackIndex >= 0 && mVideoBufferInfo.size > 0) {
            outputBuffer.position(mVideoBufferInfo.offset);
            outputBuffer.limit(mVideoBufferInfo.offset + mVideoBufferInfo.size);
            mVideoBufferInfo.presentationTimeUs = (System.nanoTime() / 1000);
            synchronized (mMediaMuxerLock) {
                if (!mIsFirstVideoSampleWrite.get()) {
                    Log.d(TAG, "First video sample written.");
                    mIsFirstVideoSampleWrite.set(true);
                }
                Log.d(TAG, "write video Data");
                mMediaMuxer.writeSampleData(mVideoTrackIndex, outputBuffer, mVideoBufferInfo);
            }
        }
        // Release data
        mVideoEncoder.releaseOutputBuffer(bufferIndex, false);
        // Return true if EOS is set
        return (mVideoBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
    }

中止录制,咱们仅仅设置了变量与音频中止的信号:

    /**
     * 中止录制
     */
    public void stopRecording() {
        if (Looper.getMainLooper() != Looper.myLooper()) {
            CameraXExecutors.mainThreadExecutor().execute(() -> stopRecording());
            return;
        }
        Log.d(TAG, "stopRecording");
        if (!mEndOfAudioVideoSignal.get() && mIsRecording) {
            // 中止音频编码器线程并等候视频编码器与封装器中止
            mEndOfAudioStreamSignal.set(true);
        }
    }
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public void release() {
        stopRecording();
        if (mRecordingFuture != null) {
            mRecordingFuture.addListener(() -> releaseResources(),
                    CameraXExecutors.mainThreadExecutor());
        } else {
            releaseResources();
        }
    }
    private void releaseResources() {
        mVideoHandlerThread.quitSafely();
        mAudioHandlerThread.quitSafely();
        if (mAudioEncoder != null) {
            mAudioEncoder.release();
            mAudioEncoder = null;
        }
        if (mAudioRecorder != null) {
            mAudioRecorder.release();
            mAudioRecorder = null;
        }
        if (mCameraSurface != null) {
            releaseCameraSurface(true);
        }
    }

成果便是先中止了音频信号,然后中止了视频信号,当视频编码悉数结束之后,中止了封装合成器,此刻回调出去奉告用户结束录制:

Android录制视频,硬编实现特效视频的录制(一)

二、结合Camera2的运用

之前咱们是在 CameraX 中运用的,现在咱们怎么在 Camera2 中运用呢?

因为咱们的输入源是 Surface,录制的方法是这样:

format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);

InputSurface = mVideoEncoder.createInputSurface();

所以咱们也很灵活,直接把输入的 Surface 绑定到 Camera2 的 Preview 目标上即可:

这儿咱们还是以之前讲到过的 Camera2 封装方法来进行绑定:

public class Camera2SurfaceProvider extends BaseCommonCameraProvider {
    public Camera2SurfaceProvider(Activity mContext) {
        super(mContext);
        ...
    }
    private void initCamera() {
        ...
        if (mCameraInfoListener != null) {
            mCameraInfoListener.getBestSize(outputSize);
            //初始化录制东西类
            VideoCaptureUtils.RecordConfig recordConfig = new VideoCaptureUtils.RecordConfig.Builder().build();
            //Surface 录制东西类
            videoCaptureUtils = new VideoCaptureUtils(recordConfig, outputSize);
        }
    }
      public void startPreviewSession(Size size) {
        try {
            releaseCameraSession(session);
            mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
            ...
            //增加预览的TextureView
             Surface previewSurface = new Surface(surfaceTexture);
            mPreviewBuilder.addTarget(previewSurface);
            outputs.add(previewSurface);
            //这儿设置输入Surface编码的数据源
            //运用 mVideoEncoder.createInputSurface() 的方法创立的Surface
            Surface inputSurface = videoCaptureUtils.mCameraSurface;
            mPreviewBuilder.addTarget(inputSurface);
            outputs.add(inputSurface);
            mCameraDevice.createCaptureSession(outputs, mStateCallBack, mCameraHandler);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }

等于是把硬件摄像头的数据绑定到不同的 Surface 上了,绑定到预览的 TextureView 上面便是展示画面了,绑定到了录制的 Surface 上就开端录制了。

那么此刻就有一个问题,假如咱们在预览的 Surface 上面展示特效的滤镜,录制的 Surface 上能终究出现出来吗?

其实上面那一句总结已经很理解了,现在我们的数据来源都是硬件摄像头的数据,我们是平级的,你鲁迅的特效跟我周树人有什么关系。

预览的Surface,录制的Surface,两个是平级关系,各自拿到的是同一个数据我们各玩各的,你做你的显示,我做我的编码,可能连巨细、尺度、比例都不一致,就更不说特效什么的了。

三、预览与录制

我不信!我看看作用。

Android录制视频,硬编实现特效视频的录制(一)

这儿给出一个图,全体是预览的特效图加上了灰度的滤镜,右边是录制的作用图。能够看到预览与录制是各玩各的。

我不信!静态图是你画上去的,我要看录制出来的作用!

Android录制视频,硬编实现特效视频的录制(一)

为什么会这样?因为预览的Surface与录制的Surface是平级的,他们不是相似OkHttp那样的拦截器那样的方法,你处理了我再依据你处理的成果进行操作。

那我能不能让预览的Surface把它展示的数据的传递过来呢,这…有主意,但他们甚至都不是一个线程的,先不说能不能拿到处理后的数据,就说线程间的通讯也存在性能开支与延时。有主意但不靠谱。

我看XX应用就能这样,为什么别人能做你不能做?

这个当然是能做的。其实目前市面上比较常用的计划便是把 特效/滤镜 作用抽取出来,然后分别在预览的Surface和录制的Surface上生效。

关于这一点后期会讲到。

总结

本文是特效录制的第一步,结束了 inputSurface 输入源的硬编码,以及在 Camera2 上面的运用,一起参加预览 Surface 与录制 Surface ,已经它们的出现作用。

咱们理解了预览与录制的关系,为什么不能不能结束特效录制的直出作用,以及怎么能结束特效录制直出的方法。

咱们一步一步来,直到咱们终究结束录制特效直出的作用,下一篇文章咱们会先说一下应用开发中常用的滤镜/特效的几种结束计划。

本文假如贴出的代码有不全的,能够点击源码翻开项目进行查看,【传送门】。一起你也能够关注我的开源项目,后续一些改动与优化还有新功用都会持续更新。

我这么菜为什么会出这样的音视频文章?因为自己的岗位是应用开发,而业务需求一些轻度音视频录制相关的功用(能够参阅拼多多评论页面)。属于那种能够特效录制视频,也能够挑选本地视频进行处理,能够挑选去除原始音频参加自界说的背景音乐,能够增加简略的文本字幕或标签,进行简略的裁剪之类的功用,在2023年的今日来看,其实都已经算是应用开发的领域,并没有涉及专业的音视频知识(并没有剪映抖音快手那么NB的作用)。我自己其实并不太懂专业音视频知识也不是拿手这方面,假如本文的解说有什么错漏的当地,期望同学们一定要指出哦。有疑问也能够评论区交流学习进步,谢谢!

当然假如觉得本文还不错对你有些协助的话,还请点赞支撑一下哦,你的支撑是我最大的动力啦!

Ok,这一期就此结束。

Android录制视频,硬编实现特效视频的录制(一)