实时视频传输过程中,声网 SDK 通常会启动默认的视频模块进行采集和渲染。在以下场景中,你可能会发现默认的视频模块无法满足开发需求:
基于此,声网 Native SDK 支持使用自定义的视频源或渲染器,实现相关场景。本文介绍如何实现自定义视频采集和渲染。
开始自定义采集和渲染前,请确保你已在项目中实现基本的通话或者直播功能,详见一对一通话或互动直播。
我们在 GitHub 上提供以下开源的示例项目:
你可以前往下载,或查看其中的源代码。
声网 Native SDK 目前提供 Push 和 MediaIO 两种方式实现自定义的视频源。其中:
setExternalVideoSource 指定自定义视频源。你需要使用自采集模块驱动采集设备对视频进行采集,采集的视频帧通过 pushVideoFrame 发送给 SDK。setVideoSource 指定自定义视频源,通过调用 consumeByteBufferFrame,consumeByteArrayFrame,或 consumeTextureFrame 传递到 SDK 读取自采集模块的视频帧并将视频帧发送给 SDK。参考如下步骤,在你的项目中使用 Push 方式实现自定义视频源功能:
joinChannel 前通过调用 setExternalVideoSource 指定自定义视频源。AgoraVideoFrame 修改视频数据。比如,设置 rotation 为 180,使视频帧顺时针旋转 180 度。pushExternalVideoFrame 发送给 SDK 进行后续操作。参考下图时序在你的项目中实现自定义视频采集。
isTextureEncodeSupported 方法检查并根据结果赋值 setExternalVideoSource 方法的 useTexture 参数。
Push 方式的数据流转过程如下:
pushExternalVideoFrame 传递到 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));
// 指定自定义视频源
engine.setExternalVideoSource(true, true, true);
// 加入频道
int res = engine.joinChannel(accessToken, channelId, "Extra Optional Data", 0);
// TextureView 的 SurfaceTexture 可用时,触发该回调
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
Log.i(TAG, "onSurfaceTextureAvailable");
mTextureDestroyed = false;
mSurfaceWidth = width;
mSurfaceHeight = height;
mEglCore = new EglCore();
mDummySurface = mEglCore.createOffscreenSurface(1, 1);
mEglCore.makeCurrent(mDummySurface);
mPreviewTexture = GlUtil.createTextureObject(GLES11Ext.GL_TEXTURE_EXTERNAL_OES);
// 创建新的 SurfaceTexture 对象,用于摄像头预览
mPreviewSurfaceTexture = new SurfaceTexture(mPreviewTexture);
// 通过 Android 原生方法 setOnFrameAvailableListener 创建 OnFrameAvailableListener,监听是否有新的视频帧可用于 SurfaceTexture。如果有则触发 onFrameAvailable 回调
mPreviewSurfaceTexture.setOnFrameAvailableListener(this);
mDrawSurface = mEglCore.createWindowSurface(surface);
mProgram = new ProgramTextureOES();
if (mCamera != null || mPreviewing) {
Log.e(TAG, "Camera preview has been started");
return;
}
try {
// 开启摄像头。这里使用了 Android 原生 Camera 类。
mCamera = Camera.open(mFacing);
// 你需要选择根据使用场景最合适的分辨率
Camera.Parameters parameters = mCamera.getParameters();
parameters.setPreviewSize(DEFAULT_CAPTURE_WIDTH, DEFAULT_CAPTURE_HEIGHT);
mCamera.setParameters(parameters);
// 将 mPreviewSurfaceTexture 设为显示摄像头预览的 SurfaceTexture
mCamera.setPreviewTexture(mPreviewSurfaceTexture);
// 指定预览屏幕为竖屏(portrait)模式,需要将预览图像顺时针旋转 90 度才能保证图像一直为竖屏模式
mCamera.setDisplayOrientation(90);
// 摄像头开始采集数据并将视频帧渲染到设定的 SurfaceView
mCamera.startPreview();
mPreviewing = true;
}
catch (IOException e) {
e.printStackTrace();
}
}
当 TextureView 中出现新的视频帧时,触发 onFrameAvailable 回调(Android 原生方法,参考 Android 官方文档)。回调执行以下操作:
pushExternalVideoFrame 推送视频帧到 SDK。// 通过 onFrameAvailable 回调从 SurfaceTexture 获取新的视频帧
// 使用 EGL 对视频帧进行自渲染,用于本地播放
// 调用 pushExternalVideoFrame 推送视频帧到 SDK
public void onFrameAvailable(SurfaceTexture surfaceTexture) {
if (mTextureDestroyed) {
return;
}
if (!mEglCore.isCurrent(mDrawSurface)) {
mEglCore.makeCurrent(mDrawSurface);
}
// 调用 updateTexImage() 将数据更新到 OpenGL ES 纹理对象
// 调用 getTransformMatrix() 转换纹理坐标
try {
mPreviewSurfaceTexture.updateTexImage();
mPreviewSurfaceTexture.getTransformMatrix(mTransform);
}
catch (Exception e) {
e.printStackTrace();
}
// 设置 MVP 矩阵
if (!mMVPMatrixInit) {
// 本示例指定 activity 为竖屏模式。采集的图像会旋转 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(mDrawSurface);
// 如果当前用户已加入频道,则设置外部视频帧并向 SDK 推送外部视频帧
if (joined) {
// 设置外部视频帧
AgoraVideoFrame frame = new AgoraVideoFrame();
frame.textureID = mPreviewTexture;
frame.format = AgoraVideoFrame.FORMAT_TEXTURE_OES;
frame.transform = mTransform;
frame.stride = DEFAULT_CAPTURE_HEIGHT;
frame.height = DEFAULT_CAPTURE_WIDTH;
frame.eglContext14 = mEglCore.getEGLContext();
frame.timeStamp = System.currentTimeMillis();
// 向 SDK 推送外部视频帧
boolean a = engine.pushExternalVideoFrame(frame);
Log.e(TAG, "pushExternalVideoFrame:" + a);
}
}
声网通过 MediaIO 提供 IVideoSource 接口和 IVideoFrameConsumer 类,你可以通过该类设置采集的视频数据格式,并控制视频的采集过程。
参考如下步骤,在你的项目中使用 MediaIO 方式实现自定义视频源功能:
实现 IVideoSource 接口。声网通过 IVideoSource 接口下的各回调设置视频数据格式,并控制采集过程:
收到 getBufferType 回调后,在该回调的返回值中指定想要采集的视频数据格式。
收到 onInitialize 回调后,保存该回调中的 IVideoFrameConsumer 对象。声网通过 IVideoFrameConsumer 对象发送和接收自定义的视频数据。
收到 onStart 回调后,通过 IVideoFrameConsumer 对象中的 consumeByteBufferFrame,consumeByteArrayFrame,或 consumeTextureFrame 方法向 SDK 发送视频帧。
为满足实际使用需求,你可以在将视频帧发送回 SDK 前,修改 IVideoFrameConsumer 中视频帧参数,如 rotation。
收到 onStop 回调后,停止使用 IVideoFrameConsumer 对象向 SDK 发送视频帧。
收到 onDispose 回调后,释放 IVideoFrameConsumer 对象。
继承实现的 IVideoSource 类,构建一个自定义的视频源对象。
调用 setVideoSource 方法,将自定义的视频源对象设置给 RtcEngine。
根据场景需要,调用 startPreview、joinChannel 等方法预览或发送自定义采集的视频数据。
参考下图时序使用 MediaIO 在你的项目中实现自定义视频采集。
MediaIO 方式的数据流转如下:
consumeByteBufferFrame,consumeByteArrayFrame,或 consumeTextureFrame传递到 SDK。参考下文代码使用 MediaIO 在你的项目中实现自定义视频采集。下文代码使用本地视频文件作为自定义视频源。
IVideoSource 接口和 IVideoFrameConsumer 类,并对 IVideoSource 接口中的回调进行重写。// 实现 IVideoSource 接口
public class ExternalVideoInputManager implements IVideoSource {
...
// 在初始化视频源时,从回调获取 IVideoFrameConsumer 对象
@Override
public boolean onInitialize(IVideoFrameConsumer consumer) {
mConsumer = consumer;
return true;
}
@Override
public boolean onStart() {
return true;
}
@Override
public void onStop() {
}
// 在 IVideoFrameConsumer 被 media engine 释放时,将 IVideoFrameConsumer 对象设为空
@Override
public void onDispose() {
Log.e(TAG, "SwitchExternalVideo-onDispose");
mConsumer = null;
}
@Override
public int getBufferType() {
return TEXTURE.intValue();
}
@Override
public int getCaptureType() {
return CAMERA;
}
@Override
public int getContentHint() {
return MediaIO.ContentHint.NONE.intValue();
}
...
}
// 实现 IVideoFrameConsumer 类
private volatile IVideoFrameConsumer mConsumer;
// 设置自定义视频源
ENGINE.setVideoSource(ExternalVideoInputManager.this);
// 创建本地视频输入的 intent,设置视频参数,并设置外部视频输入
// setExternalVideoInput 方法创建一个新的 LocalVideoInput 对象,对象会获取本地视频文件的位置
// setExternalVideoInput 方法还会为 TextureView 设置 Surface Texture 监听器
// relative layout 添加 TextureView 作为子 view,用于本地预览
Intent intent = new Intent();
setVideoConfig(ExternalVideoInputManager.TYPE_LOCAL_VIDEO, LOCAL_VIDEO_WIDTH, LOCAL_VIDEO_HEIGHT);
intent.putExtra(ExternalVideoInputManager.FLAG_VIDEO_PATH, mLocalVideoPath);
if (mService.setExternalVideoInput(ExternalVideoInputManager.TYPE_LOCAL_VIDEO, intent)) {
// relative layout 删除所有子 view
fl_local.removeAllViews();
// 添加 TextureView 作为子 view
fl_local.addView(TEXTUREVIEW,
RelativeLayout.LayoutParams.MATCH_PARENT,
RelativeLayout.LayoutParams.MATCH_PARENT);
}
setExternalVideoInput 方法的实现如下:
// setExternalVideoInput 方法的实现
boolean setExternalVideoInput(int type, Intent intent) {
if (mCurInputType == type && mCurVideoInput != null
&& mCurVideoInput.isRunning()) {
return false;
}
// 创建一个新的 LocalVideoInput 对象,对象会获取本地视频文件的位置
IExternalVideoInput input;
switch (type) {
case TYPE_LOCAL_VIDEO:
input = new LocalVideoInput(intent.getStringExtra(FLAG_VIDEO_PATH));
// 如果 TextureView 不为 null,则为此 TextureView 设置 Surface Texture 监听器
if (TEXTUREVIEW != null) {
TEXTUREVIEW.setSurfaceTextureListener((LocalVideoInput) input);
}
break;
...
}
// 将新的 LocalVideoInput 对象作为视频源
setExternalVideoInput(input);
mCurInputType = type;
return true;
}
Surface。// 解码本地视频文件并渲染到 Surface
LocalVideoThread(String filePath, Surface surface) {
initMedia(filePath);
mSurface = surface;
}
ExternalVideoInputThread 线程中的 consumeTextureFrame 消费视频帧,并将视频帧发送到 SDK。public void run() {
...
// 调用 updateTexImage() 将数据更新到 OpenGL ES 纹理对象
// 调用getTransformMatrix() 转换纹理坐标
try {
mSurfaceTexture.updateTexImage();
mSurfaceTexture.getTransformMatrix(mTransform);
}
catch (Exception e) {
e.printStackTrace();
}
// 通过 onFrameAvailable 回调获取采集的视频帧信息。此处的 onFrameAvailable 为 Android 原生方法在 LocalVideoInput 类中的重写。
// onFrameAvailable 回调通过本地预览的 SurfaceTexture 创建 EGL Surface 并将其 context 作为当前 context。该回调可以在本地渲染视频,还可以获取 Texture ID,transform 信息,用于将视频帧发送到 SDK。
if (mCurVideoInput != null) {
mCurVideoInput.onFrameAvailable(mThreadContext, mTextureId, mTransform);
}
// 关联 EGLSurface
mEglCore.makeCurrent(mEglSurface);
// 设置 EGL 观口
GLES20.glViewport(0, 0, mVideoWidth, mVideoHeight);
if (mConsumer != null) {
Log.e(TAG, "推流的宽高->width:" + mVideoWidth + ",height:" + mVideoHeight);
// 调用 consumeTextureFrame 消费视频帧并发送到 SDK
mConsumer.consumeTextureFrame(mTextureId,
TEXTURE_OES.intValue(),
mVideoWidth, mVideoHeight, 0,
System.currentTimeMillis(), mTransform);
}
// 等待下一帧
waitForNextFrame();
...
}
如果你的 app 已有自己的采集模块,需要集成声网 SDK 实现实时音视频功能,你可以使用声网 SDK 提供的组件通过 Media Engine 的回调来打开和关闭视频帧的输入。详见使用声网 SDK 提供的组件自定义视频源中的描述。
你可以通过声网的 IVideoSink 接口实现自定义渲染功能。
参考如下步骤,在你的项目中使用 MediaIO 方式实现自定义渲染模块功能:
实现 IVideoSink 接口。声网通过 IVideoSink 接口下的各回调设置视频数据格式,并控制渲染过程:
getBufferType 和 getPixelFormat 回调后,在对应回调的返回值中设置你想要渲染的数据类型。onInitialize、onStart、onStop、onDispose、getEglContextHandle 回调,控制视频数据的渲染过程。IVideoFrameConsumer 类,以获取视频数据。继承实现的 IVideoSink 类,构建一个自定义的渲染模块。
调用 setLocalVideoRenderer 或 setRemoteVideoRenderer,用于渲染本地用户或远端用户的视频。
根据场景需要,调用 startPreview、joinChannel 等方法预览或发送自定义渲染的视频数据。
参考下图时序使用 MediaIO 在你的项目中实现自定义视频渲染。
自定义视频渲染的数据流转如下:
consumeByteBufferFrame、consumeByteArrayFrame 或 consumeTextureFrame传递到自渲染模块。参考下文代码使用 MediaIO 在你的项目中实现自定义视频渲染。
为了方便开发者集成和创建自定义的视频渲染器,声网提供了一些辅助类和示例代码;开发者也可以直接使用这些组件,或者利用这些组件构建自定义的渲染器,详见使用声网 SDK 提供的组件自定义渲染器。
本地用户加入频道后,导入并实现 AgoraSurfaceView 类并设置远端视频渲染。声网 SDK 提供的 AgoraSurfaceView 类继承了 SurfaceView 同时实现了 IVideoSink 类,而且内嵌 BaseVideoRenderer 对象作为渲染模块。因此你无需自行实现 IVideoSink 类和自定义渲染模块。BaseVideoRenderer 对象使用 OpenGL 渲染,也创建了 EGLContext,可以共享 EGLContext 的 Handle 给 Media Engine。关于 AgoraSurfaceView 类的使用方法详见示例项目。
@Override
public void onUserJoined(int uid, int elapsed) {
super.onUserJoined(uid, elapsed);
Log.i(TAG, "onUserJoined->" + uid);
showLongToast(String.format("user %d joined!", uid));
Context context = getContext();
if (context == null) {
return;
}
handler.post(() ->
{
// 实现 AgoraSurfaceView 类
AgoraSurfaceView surfaceView = new AgoraSurfaceView(getContext());
surfaceView.init(null);
surfaceView.setZOrderMediaOverlay(true);
// 调用内嵌的 BaseVideoRenderer 对象的 setBufferType 和 setPixelFormat 方法设置视频帧类型和格式
surfaceView.setBufferType(MediaIO.BufferType.BYTE_BUFFER);
surfaceView.setPixelFormat(MediaIO.PixelFormat.I420);
if (fl_remote.getChildCount() > 0) {
fl_remote.removeAllViews();
}
fl_remote.addView(surfaceView);
// 设置远端视频渲染
engine.setRemoteVideoRenderer(uid, surfaceView);
});
}
你可以自行实现 IVideoSink 接口,并继承实现的类,构建一个自定义的渲染模块。
// 先创建一个实现 IVideoSink 接口的实例
IVideoSink sink = new IVideoSink() {
@Override
// 初始化渲染器。你可以在该方法中对渲染器进行初始化,也可以提前初始化好。将返回值设为 true,表示已完成初始化
public boolean onInitialize () {
return true;
}
@Override
// 启动渲染器
public boolean onStart() {
return true;
}
@Override
// 停止渲染器
public void onStop() {
}
@Override
// 释放渲染器
public void onDispose() {
}
@Override
public long getEGLContextHandle() {
// 构造你的 EGL context
// 返回 0 代表渲染器中并没有创建 EGL context
return 0;
}
// 返回当前渲染器需要的数据 Buffer 类型
// 若切换 VideoSink 的类型,必须重新创建另一个实例
// 有三种类型:BYTE_BUFFER(1);BYTE_ARRAY(2);TEXTURE(3)
@Override
public int getBufferType() {
return BufferType.BYTE_ARRAY;
}
// 返回当前渲染器需要的 Pixel 格式
@Override
public int getPixelFormat() {
return PixelFormat.NV21;
}
// SDK 调用该方法将获取到的视频帧传给渲染器
// 根据获取到的视频帧的格式,选择相应的回调
@Override
public void consumeByteArrayFrame(byte[] data, int format, int width, int height, int rotation, long timestamp) {
// 渲染器在此渲染
}
public void consumeByteBufferFrame(ByteBuffer buffer, int format, int width, int height, int rotation, long timestamp) {
// 渲染器在此渲染
}
public void consumeTextureFrame(int textureId, int format, int width, int height, int rotation, long timestamp, float[] matrix) {
// 渲染器在此渲染
}
}
rtcEngine.setLocalVideoRenderer(sink);
自定义视频采集和渲染场景中,需要开发者具有采集或渲染视频的能力:
自定义视频渲染场景中,当 consumeByteArrayFrame 或 consumeByteBufferFrame 或 consumeTextureFrame 回调报告 rotation 不为 0 时,自渲染的视频会呈一定角度。该角度可能由 SDK 采集或自采集的设置引起,你需要根据实际使用需求处理自渲染的视频角度。
如果自采集的视频格式为 Texture 且远端用户看到本地自采集的视频画面异常(例如,闪烁、变形),声网推荐你在将自采集数据传回 SDK 前做一次视频数据拷贝,然后将原视频数据和拷贝视频数据都传回 SDK。这可以消除内部数据编码过程中的异常。详细步骤可以参考使用组件自定义视频源和渲染器。