实时视频传输过程中,声网 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。这可以消除内部数据编码过程中的异常。详细步骤可以参考使用组件自定义视频源和渲染器。