查看原文
其他

Android特效视频Surface+Camera2的实现

newki 鸿洋
2024-08-24

本文作者


作者:newki

链接:

https://juejin.cn/post/7262358127345762364

本文由作者授权发布。


前言

本文并非专业音视频领域的文章,只不过是其在 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有什么区别?
额,其实也不然,因为我们最终是为了特效视频的录制直出,所以这些都是前置技能。

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

1仿VideoCapture实现完整录制


前文的我们代码中,我们用到异步回调的方式,与同步的方式来进行自定义的 MediaCodec 编码,如果是异步的方式,我们添加了结束标志符就能正确的完成录制,问题倒是不大。
而我们使用同步的方式来进行录制,它的停止是由事件触发的,结果就是立即停止,导致正在编码的数据最终丢失了,结果就是我们录制的10秒的视频结果之后8秒。
而最好的解决方案应该是,收到停止录制的信号,设置一个flag,让编码器继续执行直到轨道中没有数据了才算录制完成,此时再通过一个回调的方式暴露最终的录制地址,这样才是比较好的效果。
也就是我们需要模仿 VideoCapture 实现的录制效果。
首先我们对输出的文件对象做封装,可以是多种类型的格式,一般我们使用的 File 的输出。
然后我们就能定义一个回调,当录制真正完成的时候我们回调出去,这里根据机型的性能决定的,如果高端机型编译很快,基本上是同步完成的,如果是低端机型编码速度比较慢,就会等待1秒左右完成最终的录制。
然后我们再对音视频的录制的一些参数做一个封装,这里可以定义一些默认的参数:
并且使用构建者模式的方式构建:
核心代码来咯,主线思路:
  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(), nullnull, MediaCodec.CONFIGURE_FLAG_ENCODE);

    if (mCameraSurface != null) {
        releaseCameraSurface(false);
    }

    //用于输入的Surface
    mCameraSurface = mVideoEncoder.createInputSurface();

    //初始化音频编码器
    mAudioEncoder.reset();
    mAudioEncoder.configure(createAudioMediaFormat(), nullnull, 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);
    }
}

结果就是先停止了音频信号,然后停止了视频信号,当视频编码全部完成之后,停止了封装合成器,此时回调出去告知用户完成录制:
2结合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,两个是平级关系,各自拿到的是同一个数据大家各玩各的,你做你的显示,我做我的编码,可能连大小、尺寸、比例都不一致,就更不说特效什么的了。

3预览与录制


我不信!我看看效果。
这里给出一个图,整体是预览的特效图加上了灰度的滤镜,右边是录制的效果图。可以看到预览与录制是各玩各的。
我不信!静态图是你画上去的,我要看录制出来的效果!

为什么会这样?因为预览的Surface与录制的Surface是平级的,他们不是类似OkHttp那样的拦截器那样的方式,你处理了我再根据你处理的结果进行操作。
那我能不能让预览的Surface把它展示的数据的传递过来呢,这...有想法,但他们甚至都不是一个线程的,先不说能不能拿到处理后的数据,就说线程间的通信也存在性能开销与延时。有想法但不靠谱。
我看XX应用就能这样,为什么别人能做你不能做?
这个当然是能做的。其实目前市面上比较常用的方案就是把 特效/滤镜 效果抽取出来,然后分别在预览的Surface和录制的Surface上生效。

关于这一点后期会讲到。

4总结


本文是特效录制的第一步,完成了 inputSurface 输入源的硬编码,以及在 Camera2 上面的使用,同时加入预览 Surface 与录制 Surface ,以及它们的呈现效果。
我们明白了预览与录制的关系,为什么不能不能实现特效录制的直出效果,以及如何能实现特效录制直出的方法。
我们一步一步来,直到我们最终完成录制特效直出的效果,下一篇文章我们会先说一下应用开发中常用的滤镜/特效的几种实现方案。

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

https://gitee.com/newki123456/Kotlin-Room


我这么菜为什么会出这样的音视频文章?由于本人的岗位是应用开发,而业务需要一些轻度音视频录制相关的功能(可以参考拼多多评论页面)。属于那种可以特效录制视频,也可以选择本地视频进行处理,可以选择去除原始音频加入自定义的背景音乐,可以添加简单的文本字幕或标签,进行简单的裁剪之类的功能,在2023年的今天来看,其实都已经算是应用开发的范畴,并没有涉及专业的音视频知识(并没有剪映抖音快手那么NB的效果)。我本人其实并不太懂专业音视频知识也不是擅长这方面,如果本文的讲解有什么错漏的地方,希望同学们一定要指出哦。有疑问也可以评论区交流学习进步,谢谢!
当然如果觉得本文还不错对你有些帮助的话,还请点赞支持一下哦,你的支持是我最大的动力啦!
Ok,这一期就此完结。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

包体积优化:Android编译期PNG自动化转换WEBP
Android 15 线程挂起超时崩溃与修复
从 XML 到 View 显示在屏幕上,都发生了什么?


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存