自定义视频数据处理
在实时互动过程中,你可能需要对 SDK 采集到的原始视频数据进行处理,也可能需要获取 SDK 采集并编码的视频数据。本文介绍如何通过不同类型的视频观测器获取 SDK 采集到的视频数据,并对其进行处理。
适用场景
自定义视频数据处理适用于以下场景。
- 自定义美颜处理:拉取本地采集的原始视频流,并使用第三方美颜处理工具进行处理。处理后的视频流可以再次推送到频道中,供观看者实时观看。
- 自定义视频编辑:在视频处理链路的不同阶段对视频流进行编辑处理,如添加水印、裁剪、剪辑、特效等。用户可以根据需求对视频流进行灵活编辑,编辑后的视频流可用于直播或存储。
- 拉流预览或监控:在视频处理链路的不同阶段拉取视频流进行本地或云端的监控和预览。用户可以实时查看视频流的质量、内容和状态,确保视频流的正常传输和播放。
技术原理
SDK 中视频模块的数据处理流程如下图所示。
根据上图所示,在不同的模式下,观测点对应的回调触发时机也有所不同。SDK 默认采用只读模式,你可以根据自己的需求设置读写模式(详见实现方法中的相应步骤)。
观测点对应的回调如下所示:
- 观测点 1:通过
onCaptureVideoFrame
回调获取未经过缩放的数据。 - 观测点 2:通过
onPreEncodeVideoFrame
回调获取编码前的数据。 - 观测点 3:通过
onEncodedVideoFrameReceived
回调获取编码后的数据。 - 观测点 4:通过
onRenderVideoFrame
回调获取渲染前的数据。
- 视频前处理:视频前处理插件(包括 SDK 内置插件以及拓展插件)的默认加载位置、位于本地预览默认位置和视频帧的抽帧或缩放处理之前。
- 编码前处理:位于本地预览默认位置和视频帧的抽帧或缩放处理之后。
- 视频后处理:在视频解码之后,对视频帧进行超分辨率、超级画质处理等处理。
前提条件
在进行操作之前,请确保你已经在项目中实现了基本的实时音视频功能。详见实现音视频互动。
实现方法
根据你的实际业务需求,在以下方案中选择实现视频数据处理的方法。
观测原始数据
参考如下步骤,获取 SDK 采集的原始数据并进行处理。
-
调用
registerVideoFrameObserver
方法注册原始视频观测器IVideoFrameObserver
,同时注册相应的回调。Javaengine.registerVideoFrameObserver(iVideoFrameObserver);
-
(可选)视频观测器默认的观测位置是
VIDEO_MODULE_POSITION_POST_CAPTURER
(对应onCaptureVideoFrame
回调) 和VIDEO_MODULE_POSITION_PRE_RENDERER
(对应onRenderVideoFrame
回调),你可以在getObservedFramePosition
回调的返回值中设置预期的视频观测位置。以下示例代码以观测位置为
VIDEO_MODULE_POSITION_PRE_ENCODER
为例,对应onPreEncodeVideoFrame
回调。Java@Override
public int getObservedFramePosition() {
return IVideoFrameObserver.VIDEO_MODULE_POSITION_PRE_ENCODER;
} -
(可选)SDK 默认不接收经过处理后的视频数据,如果你要修改此行为,可在
getVideoFrameProcessMode
回调中设置预期的视频处理模式为1
:- 0:(默认)只读模式,不需要将处理后的数据发回给 SDK。
- 1:读写模式,需要将处理后的数据发回给 SDK。
Java@Override
public int getVideoFrameProcessMode() {
return IVideoFrameObserver.PROCESS_MODE_READ_WRITE;
} -
(可选)SDK 默认提供的视频数据格式为 I420Buffer 或 TextureBuffer,如果你需要其他格式的视频数据,可在
getVideoFormatPreference
回调的返回值中设置预期的数据格式:- 0:(默认)原始视频像素格式
- 1:I420 格式
- 4:RGBA 格式
- 16:I422 格式
Java@Override
public int getVideoFormatPreference() {
return IVideoFrameObserver.VIDEO_PIXEL_DEFAULT;
} -
(可选)如果采集到视频帧中设置了旋转信息
rotation
,你可以在getRotationApplied
回调的返回值中设置视频数据按照rotation
做旋转处理。注意该回调支持的视频数据格式有:I420、RGBA、Texture。
Java@Override
public boolean getRotationApplied() {
return true;
} -
(可选)如果你希望获取镜像处理后的视频数据,可在
getMirrorApplied
回调的返回值中设置视频数据做镜像处理。注意- 该回调和
setVideoEncoderConfiguration
方法均支持设置镜像效果,声网建议你仅选择一种方法进行设置,同时使用两种方法会导致镜像效果叠加从而造成设置镜像不成功。 - 该回调支持的视频数据格式有:I420、RGBA、Texture。
Java@Override
public boolean getMirrorApplied() {
return true;
} - 该回调和
-
调用
joinChannel
[2/2] 方法加入频道,SDK 会在你设置好的观测位置回调相应的视频数据。Javaint res = engine.joinChannel(token, channelId, 0, option);
-
(可选)如果需要将处理后的视频数据传回给 SDK,则在相应回调中传入处理后的视频数据,并将回调的返回值设置为
true
(设置 SDK 接收视频帧)。以下示例代码以onCaptureVideoFrame
回调为例。注意建议你在修改
videoFrame
中的参数时,需确保修改后的参数跟视频帧缓冲区中的视频帧实际情况保持一致,否则可能导致本地预览画面和对端的视频画面出现非预期的旋转、失真等问题。Java@Override
public boolean onCaptureVideoFrame(int sourceType, VideoFrame videoFrame) {
...
//将修改后的视频数据传回 SDK
videoFrame.replaceBuffer(new NV21Buffer(nv21, width, height, null), videoFrame.getRotation(), videoFrame.getTimestampNs());
return true;
} -
调用
registerVideoFrameObserver
方法,取消注册原始视频观测器IVideoFrameObserver
,即可停止观测视频数据。Javaengine.registerVideoFrameObserver(null);
观测已编码数据
参考如下步骤,获取 SDK 采集的已编码数据并进行处理。
-
调用
registerVideoEncodedFrameObserver
方法注册视频编码观测器IVideoEncodedFrameObserver
,同时注册回调onEncodedVideoFrameReceived
。Javaengine.registerVideoEncodedFrameObserver(iVideoEncodedFrameObserver);
-
调用
joinChannel
[2/2] 方法加入频道。Javaint res = engine.joinChannel(token, channelId, 0, option);
-
通过
onEncodedVideoFrameReceived
回调获取编码后的视频数据。Java@Override
public boolean onEncodedVideoFrameReceived(ByteBuffer buffer, EncodedVideoFrameInfo info) {
if (isWrite && info.frameType == VIDEO_FRAME_TYPE_KEY_FRAME) {
Log.i(TAG, "OnEncodedVideoImageReceived uid:" + info.uid + " widht:" + info.width + " height:" + info.height +" codecType:" + info.codecType + " frameType:" + info.frameType);
isWrite = false;
File cacheDir = getContext().getExternalCacheDir();
if (cacheDir == null) {
cacheDir = getContext().getCacheDir();
}
// 导出一个关键帧数据到本地存储,可以通过`ffplay -i videodata`来查看画面
File file = new File(cacheDir, "videodata");
try (FileOutputStream fos = new FileOutputStream(file)) {
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
fos.write(bytes);
Log.i(TAG, "write success");
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
} -
调用
registerVideoEncodedFrameObserver
方法,取消注册视频观测器IVideoEncodedFrameObserver
,即可停止观测视频数据。Javaengine.registerVideoEncodedFrameObserver(null);
同时观测已编码数据和渲染前数据
如果你想同时获取远端用户的已编码数据(简称为 A 组)和渲染前视频数据(简称为 B 组),可参考如下步骤。
-
调用
registerVideoEncodedFrameObserver
方法注册视频编码观测器IVideoEncodedFrameObserver
,同时注册回调onEncodedVideoFrameReceived
。Javaengine.registerVideoEncodedFrameObserver(iVideoEncodedFrameObserver);
-
调用
registerVideoFrameObserver
方法注册原始视频观测器IVideoFrameObserver
,同时注册相应的回调。Javaengine.registerVideoFrameObserver(iVideoFrameObserver);
-
调用
joinChannel
[2/2] 方法加入频道,并设置autoSubscribeVideo
为false
(不自动订阅任何视频流)。JavaChannelMediaOptions option = new ChannelMediaOptions();
option.autoSubscribeVideo = false;
int res = engine.joinChannel(token, channelId, 0, option); -
(可选)SDK 默认订阅所有远端原始数据和编码后的数据(即
encodedFrameOnly
为false
)和视频大流,如果你想针对不同uid
设置不同的订阅选项,可调用setRemoteVideoSubscriptionOptions
方法设置。信息SDK 对远端视频流的默认订阅行为取决于注册的视频观测器类型:
- 如果注册的是
IVideoFrameObserver
观测器,则默认订阅原始数据和编码后的数据。 - 如果注册的是
IVideoEncodedFrameObserver
观测器,则默认仅订阅编码后的数据。 - 如果注册了两种观测器,则默认跟随后注册的视频观测器。在本场景中,由于后注册的是
IVideoFrameObserver
观测器,所以默认订阅原始数据和编码后的数据,且 A 组和 B 组数据都可以按照预期被观测到。
Java@Override
public void onUserJoined(int uid, int elapsed) {
VideoSubscriptionOptions options = new VideoSubscriptionOptions();
options.streamType = REMOTE_VIDEO_STREAM_LOW;
options.encodedFrameOnly = true;
engine.setRemoteVideoSubscriptionOptions(uid, options);
} - 如果注册的是
-
调用
muteRemoteVideoStream
方法,并将muted
设置为false
(订阅远端用户的视频流),开始接收远端用户的视频流。此时:Javaengine.muteRemoteVideoStream(uid, false);
-
通过相应的回调获取预期的数据:
- 可通过
IVideoEncodedFrameObserver
中的onEncodedVideoFrameReceived
回调获取用户已编码的视频数据。 - 可通过
IVideoFrameObserver
中的onRenderVideoFrame
回调获取用户渲染前的视频数据。
Java@Override
public boolean onEncodedVideoFrameReceived(ByteBuffer buffer, EncodedVideoFrameInfo info) {
if (isWrite && info.frameType == VIDEO_FRAME_TYPE_KEY_FRAME) {
Log.i(TAG, "OnEncodedVideoImageReceived uid:" + info.uid + " widht:" + info.width + " height:" + info.height +" codecType:" + info.codecType + " frameType:" + info.frameType);
isWrite = false;
File cacheDir = getContext().getExternalCacheDir();
if (cacheDir == null) {
cacheDir = getContext().getCacheDir();
}
// 导出一个关键帧数据到本地存储,可以通过`ffplay -i videodata`来查看画面
File file = new File(cacheDir, "videodata");
try (FileOutputStream fos = new FileOutputStream(file)) {
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
fos.write(bytes);
Log.i(TAG, "write success");
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
@Override
public boolean onRenderVideoFrame(String s, int i, VideoFrame videoFrame) {
// 自定义渲染远端用户的画面
if (mSurfaceView != null && videoFrame != lastI420Frame) {
Log.d(TAG, "onRenderVideoFrame: " + i + " connection: " + s + " buffer: " + videoFrame.getBuffer());
lastI420Frame = videoFrame;
textureBufferHelper.invoke(new Callable<Void>() {
@Override
public Void call() throws Exception {
if (lastI420Frame.getBuffer() instanceof VideoFrame.I420Buffer) {
final VideoFrame.I420Buffer i420Buffer = (VideoFrame.I420Buffer) lastI420Frame.getBuffer();
GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1);
yuvUploader.uploadFromBuffer(i420Buffer);
}
return null;
}
});
mSurfaceView.requestRender();
}
return false;
} - 可通过
-
分别调用
registerVideoEncodedFrameObserver
和registerVideoFrameObserver
方法,取消注册视频观测器,即可停止观测视频数据。Java// 取消注册已编码视频观测器
engine.registerVideoEncodedFrameObserver(null);
// 取消注册原始视频观测器
engine.registerVideoFrameObserver(null);
示例项目
声网提供了开源的原始视频数据示例项目供你参考,你可以前往下载或查看其中的源代码。