对于一个音视频通话,你可以将其中的多路音视频流合为一路,并将合并得到的音视频流推送到指定的推流地址(通常是 CDN 地址)。你可以在应用服务端和应用客户端启动合流转推,本文介绍如何通过调用客户端 API,在 RTC 服务端发起和完成合流转推任务。
关于如何调用 Open API,在服务端完成合流转推,参见 通过 OpenAPI 使用合流转推功能 。
前提条件 你已经集成 RTC SDK,实现了基本的音视频通话 。
调用时序
1. 创建引擎类 创建和初始化一个音视频引擎类。
参考 构建 RTC 应用 获取详细步骤。
// 首先定义:
// IRTCEngineEventHandler rtcEngineEventHandler = new IRTCEngineEventHandler() {
// @Override public void onWarning(int warn) { ... }
// @Override public void onError(int err) { ... }
// }
// 创建引擎
EngineConfig config = new EngineConfig();
config.appID = appId;
config.context = applicationContext;
rtcEngine = RTCEngine.createRTCEngine(config, rtcEngineEventHandler);
// 开启音视频采集
rtcEngine.startVideoCapture();
rtcEngine.startAudioCapture();
//创建引擎
let engineCfg = ByteRTCEngineConfig.init()
engineCfg.appID = appId
engineCfg.parameters = [:]
self.rtcEngine = ByteRTCEngine.createRTCEngine(engineCfg, delegate: self)
// 开启音视频采集
self?.rtcEngine?.startVideoCapture()
self?.rtcEngine?.startAudioCapture()
//创建引擎
bytertc::EngineConfig conf;
conf.app_id = g_appid.c_str();
m_engine = bytertc::IRTCEngine::createRTCEngine(conf, handler.get());
// 开启音视频采集
m_engine->startAudioCapture();
m_engine->startVideoCapture();
2. 进房 创建房间实例后,你可以加入房间发布和订阅音视频流。建议设置房间回调接口,以便监听房间和音视频流的状态回调。
private void joinRoom(String roomId) {
// 创建房间
rtcRoom = rtcEngine.createRTCRoom(roomId);
rtcRoom.setRTCRoomEventHandler(roomEventHandler);
String token = requestRoomToken(roomId);
// 用户信息
UserInfo userInfo = new UserInfo(localUid, "");
// 房间配置
boolean isAutoPublishAudio = true;
boolean isAutoPublishVideo = true;
boolean isAutoSubscribeAudio = true;
boolean isAutoSubscribeVideo = true;
RTCRoomConfig roomConfig = new RTCRoomConfig(ChannelProfile.CHANNEL_PROFILE_CHAT_ROOM, isAutoPublishAudio, isAutoPublishVideo, isAutoSubscribeAudio, isAutoSubscribeVideo);
// 加入房间
rtcRoom.joinRoom(token, userInfo, true, roomConfig);
}
// 创建房间
self.rtcRoom = self.rtcEngine?.createRTCRoom(roomId)
self.rtcRoom?.delegate = self
// 用户信息
let userInfo = ByteRTCUserInfo.init()
userInfo.userId = userId
// 房间配置
let roomCfg = ByteRTCRoomConfig.init()
roomCfg.isPublishAudio = true
roomCfg.isPublishVideo = true
roomCfg.isAutoSubscribeAudio = true
roomCfg.isAutoSubscribeVideo = true
// 加入房间
self.rtcRoom?.joinRoom(token, userInfo: userInfo, userVisibility: true, roomConfig: roomCfg)
// 创建房间
bytertc::IRTCRoom *room = engine->createRTCRoom(id);
// 加入房间
bytertc::UserInfo info;
bytertc::RTCRoomConfig config;
info.uid = uid.c_str();
config.is_auto_publish_audio = true;
config.is_auto_publish_video = true;
config.is_auto_subscribe_audio = true;
config.is_auto_subscribe_video = true;
config.room_profile_type = bytertc::kRoomProfileTypeCommunication;
int ret = room->joinRoom(token.c_str(), info, true, config);
3. 开启任务 发起合流转推任务,在收到 onRoomStateChanged 回调,进入 RTC 房间成功后调用 startPushMixedStream。 private void startPushCDNStream() {
MixedStreamPushTargetConfig targetConfig = new MixedStreamPushTargetConfig();
targetConfig.pushTargetType = MixedStreamPushTargetType.PUSH_TO_CDN;
targetConfig.pushCDNURL = "CDN_URL";
MixedStreamConfig mixedStreamConfig = MixedStreamConfig.defaultMixedStreamConfig();
mixedStreamConfig.userID = 发起任务的用户ID;
mixedStreamConfig.roomID = 房间ID;
//视频相关参数
mixedStreamConfig.videoConfig.width = 360;
mixedStreamConfig.videoConfig.height = 640;
mixedStreamConfig.videoConfig.fps = 15;
mixedStreamConfig.videoConfig.bitrate = 500000;
//音频相关参数
mixedStreamConfig.audioConfig.channels = 2;
mixedStreamConfig.audioConfig.sampleRate = 48000;
mixedStreamConfig.audioConfig.bitrate = 64;
//布局相关参数
mixedStreamConfig.regions = getLayoutRegions();
rtcEngine.startPushMixedStream(taskID, targetConfig, mixedStreamConfig);
}
// 监听任务回调
IRTCEngineEventHandler engineEventHandler = new IRTCEngineEventHandler() {
@Override
public void onMixedStreamEvent(MixedStreamTaskInfo info, MixedStreamTaskEvent event, MixedStreamTaskErrorCode error) {
super.onMixedStreamEvent(info, event, error);
String msg = String.format("onMixedStreamEvent,taskId:%s, error:%s, event:%s", info.getTaskId(), error.toString(), event.toString());
Log.d(TAG, msg);
}
};
};
@objc func startPushCDNStream() {
let targetConfig = ByteRTCMixedStreamPushTargetConfig.init()
targetConfig.pushTargetType = .toCDN
targetConfig.pushCDNURL = CDN_URL
// 设置合流参数
let mixedStreamConfig = ByteRTCMixedStreamConfig.default()
mixedStreamConfig?.roomID = roomId
mixedStreamConfig?.userID = userId
//视频相关参数
mixedStreamConfig.videoConfig.width = 360
mixedStreamConfig.videoConfig.height = 640
mixedStreamConfig.videoConfig.fps = 15
mixedStreamConfig.videoConfig.bitrate = 500000
//音频相关参数
mixedStreamConfig.audioConfig.channels = 2
mixedStreamConfig.audioConfig.sampleRate = 48000
mixedStreamConfig.audioConfig.bitrate = 64
//布局相关参数
mixedStreamConfig.regions = getLayoutRegions()
// 开始推流到 CDN
rtcEngine?.startPushMixedStream(taskId, with:targetConfig, withMixedConfig: mixedStreamConfig)
}
// 监听任务回调
func rtcEngine(_ engine: ByteRTCEngine, onMixedStreamEvent event: ByteRTCMixedStreamTaskEvent, withMixedStreamInfo info: ByteRTCMixedStreamTaskInfo, with errorCode: ByteRTCMixedStreamTaskErrorCode) {
ToastComponents.shared.show(withMessage: "onMixedStreamEvent:(event.rawValue) taskId:(info.taskId) errorCode:(errorCode.rawValue) + mixType:(info.description)")
NSLog("onMixedStreamEvent: (event), info_des: (info.description), errorcode : (errorCode)")
}
// 设置合流参数
bytertc::IMixedStreamConfig* mixed_stream_param = getMixedStreamConfig();
bytertc::MixedStreamPushTargetConfig cf;
cf.push_cdn_url = url.c_str();
int ret = m_engine->startPushMixedStream(m_task.c_str(), cf, mixed_stream_param);
//回调
void onMixedStreamEvent(MixedStreamTaskInfo info, MixedStreamTaskEvent event, MixedStreamTaskErrorCode error_code) {
(void)info;
(void)event;
(void)error_code;
}
合流视频的布局设置。分别以 1x4 和 2x2 两种布局模式为例。 private MixedStreamConfig.MixedStreamLayoutRegionConfig[] getLayoutRegions() {
int width = 360;
int height = 640;
int userNum = userNameList.size();
MixedStreamConfig.MixedStreamLayoutRegionConfig[] regions = new MixedStreamConfig.MixedStreamLayoutRegionConfig[userNum];
int index = 0;
//是否是 1x4 布局
String mode = layoutMode.getSelectedItem().toString();
if ("1x4".equals(mode)) {
for (String uid : userNameList) {
MixedStreamConfig.MixedStreamLayoutRegionConfig region = new MixedStreamConfig.MixedStreamLayoutRegionConfig();
region.setRoomID(roomID);
region.setUserID(uid);
region.isLocalUser = (uid.equals(localUid));
region.setLocationX(index * width / userNum);
// 留出部分背景区域
region.setLocationY(50);
region.setWidth(width / userNum);
region.setHeight(height);
region.setAlpha(1);
region.setZOrder(0);
region.setRenderMode(MixedStreamRenderMode.MIXED_STREAM_RENDER_MODE_HIDDEN);
region.setStreamType(MixedStreamVideoType.MIXED_STREAM_VIDEO_TYPE_MAIN);
region.setMediaType(MixedStreamMediaType.MIXED_STREAM_MEDIA_TYPE_AUDIO_AND_VIDEO);
regions[index] = region;
index ++;
}
} else if ("2x2".equals(mode)) {
for (String uid : userNameList) {
MixedStreamConfig.MixedStreamLayoutRegionConfig region = new MixedStreamConfig.MixedStreamLayoutRegionConfig();
region.setRoomID(roomID);
region.setUserID(uid);
region.isLocalUser = (uid.equals(localUid));
region.setLocationX((index % 2) * width / userNum);
region.setLocationY((index / 2) * height / 2 + 50);
region.setWidth(width / 2);
// 为展示部分背景
region.setHeight(height / 2);
region.setAlpha(1);
region.setZOrder(0);
region.setRenderMode(MixedStreamRenderMode.MIXED_STREAM_RENDER_MODE_HIDDEN);
region.setStreamType(MixedStreamLayoutRegionConfig.MixedStreamVideoType.MIXED_STREAM_VIDEO_TYPE_MAIN);
region.setMediaType(MixedStreamMediaType.MIXED_STREAM_MEDIA_TYPE_AUDIO_AND_VIDEO);
regions[index] = region;
index ++;
}
}
return regions;
}
func getMixRegions() -> [ByteRTCMixedStreamLayoutRegionConfig] {
let roomId = roomSettingItem.text
let userId = userSettingItem.text
var regions = [ByteRTCMixedStreamLayoutRegionConfig]()
if self.layoutSheetView.selectedIndex == 0 {
// 1x4 布局
let width = 360/4
let height = 640
// 本地用户
let regionConfig = ByteRTCMixedStreamLayoutRegionConfig.init()
regionConfig.userID = userId!
regionConfig.roomID = roomId!
regionConfig.locationX = 0
regionConfig.locationY = 0
regionConfig.width = 360/4
regionConfig.height = 640
regionConfig.zOrder = 0
regionConfig.isLocalUser = true
regionConfig.mediaType = .audioAndVideo
regions.append(regionConfig)
//远端用户
for (index, item) in self.users.enumerated() {
if index < 3 {
let regionConfig = ByteRTCMixedStreamLayoutRegionConfig.init()
regionConfig.userID = item.userId!
regionConfig.roomID = item.roomId!
regionConfig.locationX = width*(index+1)
regionConfig.locationY = 0
regionConfig.width = width
regionConfig.height = height
regionConfig.zOrder = 0
regionConfig.isLocalUser = false
regionConfig.mediaType = .audioAndVideo
regions.append(regionConfig)
}
}
} else {
// 2x2 布局
let width = 360/2
let height = 640/2
// 本地用户
let regionConfig = ByteRTCMixedStreamLayoutRegionConfig.init()
regionConfig.userID = userId!
regionConfig.roomID = roomId!
regionConfig.locationX = 0
regionConfig.locationY = 0
regionConfig.width = width
regionConfig.height = height
regionConfig.zOrder = 0
regionConfig.isLocalUser = true
regionConfig.mediaType = .audioAndVideo
regions.append(regionConfig)
//远端用户
for (index, item) in self.users.enumerated() {
if index < 3 {
let regionConfig = ByteRTCMixedStreamLayoutRegionConfig.init()
regionConfig.userID = item.userId!
regionConfig.roomID = item.roomId!
let col = (index + 1)%2
let row = (index + 1)/2
regionConfig.locationX = width*col
regionConfig.locationY = height*row
regionConfig.width = width
regionConfig.height = height
regionConfig.zOrder = 0
regionConfig.isLocalUser = false
regionConfig.mediaType = .audioAndVideo
regions.append(regionConfig)
}
}
}
return regions
}
bytertc::IMixedStreamConfig* CDNStreamByServer::getMixedStreamConfig()
{
// audio
bytertc::MixedStreamAudioConfig audioParam;
audioParam.audio_codec = bytertc::MixedStreamAudioCodecType::kMixedStreamAudioCodecTypeAAC;
audioParam.channels = 2;
audioParam.bitrate = bitrate_a;
audioParam.sample_rate = 48000;
audioParam.audio_profile = bytertc::MixedStreamAudioProfile::kMixedStreamAudioProfileLC;
// video
bytertc::MixedStreamVideoConfig videoParam;
videoParam.bitrate = bitrate_v;
videoParam.fps = 15;
videoParam.gop = 2;
videoParam.width = 640;
videoParam.height = 360;
videoParam.enable_bframe = false;
videoParam.video_codec = bytertc::kMixedStreamVideoCodecTypeH264;
// layout
bytertc::MixedStreamLayoutRegionConfig* layouts = new bytertc::MixedStreamLayoutRegionConfig[m_rendered_users.size()];
int i = 0;
std::map<std::string, UserWidget*>::iterator ite;
for (ite = m_rendered_users.begin(); ite != m_rendered_users.end(); ite++) {
bytertc::MixedStreamLayoutRegionConfig lay;
lay.user_id = ite->first.c_str();
lay.room_id = m_roomid.c_str();
lay.location_x = (i % 2) * 0.5 * videoParam.width; //确定每个流的位置
lay.location_y = (i / 2) * 0.5 * videoParam.height;
lay.width = 0.5 * videoParam.width;
lay.height = 0.5* videoParam.height;
if (ite->first == m_localid) {
lay.is_local_user = true;
}
else {
lay.is_local_user = false;
}
lay.alpha = 1.0f;
lay.z_order = 0;
lay.render_mode = bytertc::MixedStreamRenderMode::kMixedStreamRenderModeHidden;
lay.stream_type = bytertc::MixedStreamVideoType::kMixedStreamVideoTypeMain;
lay.media_type = bytertc::MixedStreamMediaType::kMixedStreamMediaTypeAudioAndVideo;
lay.apply_spatial_audio = false;
layouts[i++] = lay;
}
std::string url = "Your Url";
bytertc::IMixedStreamConfig* mixed_stream_param = bytertc::IMixedStreamConfig::createMixedStreamConfig();
mixed_stream_param->setAudioConfig(audioParam);
mixed_stream_param->setVideoConfig(videoParam);
mixed_stream_param->setLayoutConfig(layouts, m_rendered_users.size());
mixed_stream_param->setRoomID(m_roomid.c_str());
mixed_stream_param->setUserID(m_localid.c_str());
delete[]layouts;
return mixed_stream_param;
}
4. 更新任务 在收到远端用户视频流后,才可以更新合流布局。
private void updateCDNStreamConfig() {
String cdnAddr = cdnAddressInput.getText().toString();
if (cdnAddr.isEmpty()) {
ToastUtil.showAlert(this, "cdn address is null");
return;
}
targetConfig.pushCDNURL = cdnAddr;
mixedStreamConfig.backgroundColor = layoutColorInput.getText().toString();
mixedStreamConfig.regions = getLayoutRegions();
rtcEngine.updatePushMixedStream(CDN_TASK_ID, targetConfig, mixedStreamConfig);
}
@objc func updatePushConfig() {
self.rtcEngine?.updatePushMixedStream(taskId, with: self.targetConfig, withMixedConfig: self.mixConfig!)
}
bytertc::MixedStreamPushTargetConfig cf;
cf.push_cdn_url = "Your Url";
bytertc::IMixedStreamConfig* config = getMixedStreamConfig();
int ret = m_video->updatePushMixedStream(m_task.c_str(), cf, config);
5. 结束任务 在音视频互动中,你可以随时启动或停止合流转推。
private void stopPushCDNStream() {
rtcEngine.stopPushMixedStream(CDN_TASK_ID, MixedStreamPushTargetType.PUSH_TO_CDN);
}
@objc func stopPushCDN() {
self.rtcEngine?.stopPushMixedStream(taskId, with: .toCDN)
}
int ret = m_engine->stopPushMixedStream(m_task.c_str());
6. 离房 private void leaveRoom() {
if (rtcRoom != null) {
rtcRoom.leaveRoom();
rtcRoom.destroy();
rtcRoom = null;
}
}
self?.rtcRoom?.leave()
self?.rtcRoom?.destroy()
self?.rtcRoom = nil
room->leaveRoom();
room->destroyRTCRoom();
room = nullptr;
7. 销毁引擎 RTCEngine.destroyRTCEngine();
ByteRTCEngine.destroyRTCEngine()
self.rtcEngine = nil
bytertc::IRTCEngine::destroyRTCEngine();
video = nullptr;
示例项目 常见问题
1. 如何在合流转推流中使用 SEI SEI 是视频编码格式中的补充增强信息,和视频编码帧一起打包发送,因此可以达到和视频帧发送和解析同步的效果。转推任务发起成功后,画面布局和背景等信息作为 SEI 透传到 RTMP 流中。拉流端需要自行提取和解析 SEI,例如,更新画面布局。
合流接口中传递到直播流中的信息,会在合流 I 帧前重复发送。例如,合流布局不变更,重复发送相同 SEI 数据,当合流布局变更,触发一个最新的 SEI。
在开启/更新合流时,可以通过设置 layoutConfig.userConfigExtraInfo 来设置自定义 SEI 信息。详见 发送和接收媒体补充增强信息 。比如在直播答题场景中,在 SEI 中打包题目信息,每个人听到主播讲题时,同时看到对应的题目,不会因为不同延时导致题目出现的时间与讲解不匹配。
合流的 SEI 结构示例如下,其中,自定义消息为 app_data 的值。
{
"app_data": "自定义消息",
"canvas": {
"bgnd": "#000000",
"h": 640,
"w": 360
},
"regions": [
{
"alpha": 1.0,
"contentControl": 0,
"height": 640,
"locationX": 0,
"locationY": 50,
"renderMode": 1,
"uid": "user_343",
"width": 360,
"zorder": 0
}
],
"ts": 1705994199709
}
2. 如何设置 task_id 当 APP 需要开启多个视频合流时,可以通过 task_id 来区分多个合流 ID。如果同时只有一个合流视频数据可以使用空字符串代替。startPushMixedStream 和 stopPushMixedStream 的 task_id 需要成对出现。 如果 task_id 不同,会导致合流不会关闭。
3. 如何处理发起端意外掉线后重新登录 在开启转推任务后,如果因为进行刷新页面 等操作,造成应用端进程异常终止,则转推任务会在空闲时间超过设定值后自动停止,默认空闲超时时间为180s。
重启客户端重新登录后,需先调用 stopPushMixedStream 结束上一个任务,再开启新的转推任务,以免造成多个任务同时操作一个推流地址,导致新的转推任务失败。
4. 错误码 合流转推过程中返回的错误码详见各端 API 文档。
5. 调用开启转推直播任务接口传入的用户离开房间后,转推直播任务是否会停止? 若未设置 TaskId:
该用户离开房间后,该任务会自动停止。 若设置 TaskId:
该用户离开房间任务不会停止。直至房间内无用户后,该任务会自动停止。