【征文】基于声网的视频通话中”以假乱真“技术

1. 背景

刷短视频的时候经常看到一些应对老婆视频查岗的搞笑段子,有一些是”随身带场景“,视频的时候赶紧把场景布置好,假装”老实在家“;还有一些是提前录制好视频,接通视频前把提前录制好的视频放到用于通话的手机摄像头前,为了搞笑效果,还需要提前用好几个手机录制好几段视频,挑战贼大。

基于这种虚拟的场景需求,想到有没有办法让音视频通话变得更多元,更有趣呢?比如:

  1. 视频通话的时候,视频源是本地手机已经录制好的一些视频文件;

  2. 音视频通话的时候让对方听到的时候加一些背景音;

  3. 两个手机,一个手机玩游戏,另一个手机视频通话,对方可以同时看到自己的视频画面和摄像头画面;

2. 声网SDK能力

声网SDK提供了视频自采集自渲染,音频自采集自播放的能力:

  • setExternalVideoSource 自定义视频源,官方示例代码:
 // 创建 TextureView
 TextureView textureView = new TextureView(getContext());
 // 添加 SurfaceTextureListener。如果 TextureView 中的 SurfaceTexture 可用,可触发 onSurfaceTextureAvailable 回调
 textureView.setSurfaceTextureListener(this);
 // 将 TextureView 加入本地布局
 fl_local.addView(textureView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
 ViewGroup.LayoutParams.MATCH_PARENT));

 // 指定自定义视频源
 ChannelMediaOptions option = new ChannelMediaOptions();
 option.autoSubscribeAudio = true;
 option.autoSubscribeVideo = true;
 engine.setExternalVideoSource(true, true, false);
 // 加入频道
 int res = engine.joinChannel(accessToken, channelId, 0, option);
  • pushExternalVideoFrame 将采集到的视频帧发送至 SDK
 // 通过 onFrameAvailable callback 回调从 SurfaceTexture 获取新的视频帧
 // 使用 EGL 渲染视频帧,用于本地播放
 // 调用 pushExternalVideoFrame 将视频帧发送给 SDK
 public void onFrameAvailable(SurfaceTexture surfaceTexture) {
         if (mTextureDestroyed) {
             return;
         }

           if (!mEglCore.isCurrent(mDrawSurface)) {
             mEglCore.makeCurrent(mDrawSurface);
         }
         /** 使用 surfaceTexture 时间戳,单位为纳秒 */
         long timestampNs = -1;
         try {
             surfaceTexture.updateTexImage();
             surfaceTexture.getTransformMatrix(mTransform);
             timestampNs = surfaceTexture.getTimestamp();
         }
         } catch (Exception e) {
             e.printStackTrace();
         }
         // 配置 MVP 矩阵
         if (!mMVPMatrixInit) {
             // 该示例代码将活动指定为竖屏模式。由于采集到的视频帧会旋转 90 度,因此宽高数据在计算 frame ratio 时需要互换。
             float frameRatio = DEFAULT_CAPTURE_HEIGHT / (float) DEFAULT_CAPTURE_WIDTH;
             float surfaceRatio = mSurfaceWidth / (float) mSurfaceHeight;
             Matrix.setIdentityM(mMVPMatrix, 0);

              if (frameRatio >= surfaceRatio) {
                 float w = DEFAULT_CAPTURE_WIDTH * surfaceRatio;
                 float scaleW = DEFAULT_CAPTURE_HEIGHT / w;
                 Matrix.scaleM(mMVPMatrix, 0, scaleW, 1, 1);
             } else {
                 float h = DEFAULT_CAPTURE_HEIGHT / surfaceRatio;
                 float scaleH = DEFAULT_CAPTURE_WIDTH / h;
                 Matrix.scaleM(mMVPMatrix, 0, 1, scaleH, 1);
             }
             mMVPMatrixInit = true;
         }
         // 设置视图大小
         GLES20.glViewport(0, 0, mSurfaceWidth, mSurfaceHeight);
         // 绘制视频帧
         mProgram.drawFrame(mPreviewTexture, mTransform, mMVPMatrix);
         // 将 EGL 图像 buffer 传递到 EGL Surface 用于播放,实现本地预览。mDrawSurface 是 EGLSurface 类的对象。
         mEglCore.swapBuffers(mDrawSurfa

         // 如果当前用户已加入频道,则配置外部视频帧并向 SDK 推送外部视频帧。
         if (joined) {
             // 配置外部视频帧
             VideoFrame.TextureBuffer buffer = new TextureBuffer(
                     mEglCore.getEGLContext(),
                     DEFAULT_CAPTURE_HEIGHT /* 为方便起见,将视频帧的宽高数据互换 */,
                     DEFAULT_CAPTURE_WIDTH  /* 为方便起见,将视频帧的宽高数据互换 */,
                     VideoFrame.TextureBuffer.Type.OES,
                     mPreviewTexture,
                     RendererCommon.convertMatrixToAndroidGraphicsMatrix(mTransform),
                     mHandler,
                     mYuvConverter,
                     null /* 为方便起见,可以传入 null。如果希望在场景中避免 texture,你可以调用该回调*/);
             VideoFrame frame = new VideoFrame(buffer, 0, timestampNs);
             // 将外部视频帧发送至 SDK
             boolean a = engine.pushExternalVideoFrame(frame);
             Log.e(TAG, "pushExternalVideoFrame:" + a);
         }
     }

这里面主要构造声网SDK封装的VideoFrame对象,该对象中传入VideoFrame.TextureBuffer。VideoFrame.TextureBuffer需要传入EGL上下文,纹理类型,纹理ID。

这里面有个mEglCore对象,这个对象是声网封装了EGL相关操作的EGLCore类。EGL是什么呢?EGL 是渲染 API(如 OpenGL ES)和原生窗口系统之间的接口。OpenGL是一个操作 GPU 的 API,它通过驱动向 GPU 发送相关指令,控制图形渲染管线状态机的运行状态,但是当涉及到与本地窗口系统进行交互时,就需要这么一个中间层,且它最好是与平台无关的,因此 EGL 被设计出来,作为 OpenGL 和原生窗口系统之间的桥梁。

EGL API 是独立于 OpenGL ES 各版本标准的独立的一套 API,其主要作用是为 OpenGL 指令 创建 Context 、绘制目标 Surface 、配置 FrameBuffer 属性、Swap 提交绘制结果 等。

EGL 提供如下机制:

  1. 与设备原生窗口通信

  2. 查询绘制 surface 的可用类型和配置

  3. 创建绘制 surface

  4. 在 OpenGL ES 3.0 或其他渲染 API 之间同步渲染

  5. 管理纹理贴图等渲染资源

具体API我们不在这里赘述。我们只要明白Android中OpenGL线程与EGL上下文环境强相关。

3. 使用本地视频文件作为视频源实现

既然声网提供了自采集功能,那么我们可以动手实现把视频文件中的画面内容作为视频通话的视频源了。

我们从手机读取一个MP4文件,解复用解码后就可以直接拿到画面帧。Android系统为我们提供了这解复用和解码的系统API(针对特定的编码器和复用器),我们不需要引入第三方库即可实现这个功能:

  • 解复用器:MediaExtractor

  • 解码器:MediaCodec

3.1 视频解复用

MediaExtractor使用示例:

 MediaExtractor extractor = new MediaExtractor();
 extractor.setDataSource(...);
 int numTracks = extractor.getTrackCount();
 for (int i = 0; i < numTracks; ++i) {
   MediaFormat format = extractor.getTrackFormat(i);
   String mime = format.getString(MediaFormat.KEY_MIME);
   if (weAreInterestedInThisTrack) {
     extractor.selectTrack(i);
   }
 }
 ByteBuffer inputBuffer = ByteBuffer.allocate(...)
 while (extractor.readSampleData(inputBuffer, ...) >= 0) {
   int trackIndex = extractor.getSampleTrackIndex();
   long presentationTimeUs = extractor.getSampleTime();
   ...
   extractor.advance();
 }

 extractor.release();
 extractor = null;

使用步骤:

  1. 构建MediaExtractor对象;

  2. 设置视频源(视频地址,可以是本地路径也可以是云端路径);

  3. 获取所有Tracks的媒体信息;

  4. 循环读取音视频包;

  5. 读取完成释放资源

3.2 视频解码

音视频编解码器MediaCodec提供了同步和异步两种处理方式,具体可以参考MediaCodec官方文档

上面我们可以通过MediaExtractor解析出音视频的媒体信息,封装到MediaFormat类,我们在构造解码器时可以传入MediaExtractor获取到的MediaFormat:

private MediaCodec createVideoDecoder(MediaFormat inputFormat, Surface surface) {
    MediaCodec decoder = null;
    try {
      decoder = MediaCodec.createDecoderByType(getMimeTypeFor(inputFormat));
    } catch (IOException e) {
      e.printStackTrace();
      if (listener == null) {
        listener.onCompressionError(ErrCode.Err_Decoder_Video_Create);
      }
      return null;
    }
    decoder.configure(inputFormat, surface, null, 0);
    decoder.start();
    return decoder;
  }

上面代码我们在configure时需要传入一个Surface,这个Surface怎么来呢?还记得上面声网自采集接口中用到的SurfaceTexture吗?我们直接使用上面的SurfaceTexture构造一个Surface传入解码器即可。

mSurfaceTexture = new SurfaceTexture(mTextureRender.getTextureId());
    // This doesn't work if OutputSurface is created on the thread that CTS started for
    // these test cases.
    //
    // The CTS-created thread has a Looper, and the SurfaceTexture constructor will
    // create a Handler that uses it.  The "frame available" message is delivered
    // there, but since we're not a Looper-based thread we'll never see it.  For
    // this to do anything useful, OutputSurface must be created on a thread without
    // a Looper, so that SurfaceTexture uses the main application Looper instead.
    //
    // Java language note: passing "this" out of a constructor is generally unwise,
    // but we should be able to get away with it here.
    mSurfaceTexture.setOnFrameAvailableListener(this);
    mSurface = new Surface(mSurfaceTexture);

每当解码器解码出数据后,就会通知setOnFrameAvailableListener设置的SurfaceTexture.OnFrameAvailableListener来更新数据,我们在回调方法onFrameAvailable中pushExternalVideoFrame给声网SDK进行传输,是不是无缝衔接?

剩下最后一个问题了,解复用后的数据怎么给到解码器?

3.3 解复用解码数据串联

解复用后数据通过

ByteBuffer decoderInputBuffer = videoDecoderInputBuffers[decoderInputBufferIndex];
//解复用后视频数据
int size = videoExtractor.readSampleData(decoderInputBuffer, 0);
long presentationTime = videoExtractor.getSampleTime();
      
        if (size >= 0) {//数据传输到解码器
          videoDecoder.queueInputBuffer(
              decoderInputBufferIndex,
              0,
              size,
              presentationTime,
              videoExtractor.getSampleFlags());
          videoExtractor.advance();
        } else {
          Logg.i(TAG, "video extractor: EOS");
          videoDecoder.queueInputBuffer(
              decoderInputBufferIndex,
              0,
              0,
              0,
              MediaCodec.BUFFER_FLAG_END_OF_STREAM);
          videoExtractorDone = true;
        }

4. 总结

本文我们通过OpenGL纹理相关的接口,通过系统API解码本地视频文件,实现了本地视频文件画面作为视频通话中的视频源的功能。为什么要用OpenGL呢,声网SDK也提供了非纹理方式?因为纹理方式比较起内存方式效率会高很多。

声网SDK为我们提供了丰富的自定义接口可以实现很多有意思的功能。比如可以把一张图片做视频源,也可以把视频文件中的音频内容和麦克风采集音频混音后作为音频源传给SDK,同时本地也播放着,这样可以和你的朋友边聊天边一起远程看电影。

推荐阅读
相关专栏
开发者实践
182 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。