对于一个音视频通话,你可以将其中的多路音视频流合为一路,并将合并得到的音视频流推送到指定的推流地址(通常是 CDN 地址)。你可以在应用服务端和应用客户端启动转推直播,本文介绍如何通过调用客户端 API,在 RTC 服务端发起和完成转推直播任务。

关于如何调用 Open API,在服务端完成转推直播,参见 通过 OpenAPI 使用转推直播功能。
你已经集成 RTC SDK,实现了基本的音视频通话。
支持发起转推直播的 SDK 详见API 及回调。

创建和初始化一个音视频引擎类。
参考 构建 RTC 应用 获取详细步骤。
// 创建引擎
rtcVideo = RTCVideo.createRTCVideo(this, Constants.APP_ID, videoEventHandler, null, null);
// 开启音视频采集
rtcVideo.startVideoCapture();
rtcVideo.startAudioCapture();java//创建引擎
self?.rtcVideo = ByteRTCVideo.createRTCVideo(kAppID, delegate: self, parameters: [:])
// 开启音视频采集
self?.rtcVideo?.startVideoCapture()
self?.rtcVideo?.startAudioCapture()swift//创建引擎
bytertc::IRTCVideo *video = bytertc::createRTCVideo(appid, handler, nullptr);
// 开启音视频采集
video->startAudioCapture();
video->startVideoCapture();cpp创建房间实例后,你可以加入房间发布和订阅音视频流。建议设置房间回调接口,以便监听房间和音视频流的状态回调。
private void joinRoom(String roomId) {
// 创建房间
rtcRoom = rtcVideo.createRTCRoom(roomId);
rtcRoom.setRTCRoomEventHandler(roomEventHandler);
String token = requestRoomToken(roomId);
// 用户信息
UserInfo userInfo = new UserInfo(localUid, "");
// 房间配置
boolean isAutoPublish = true;
boolean isAutoSubscribeAudio = true;
boolean isAutoSubscribeVideo = true;
RTCRoomConfig roomConfig = new RTCRoomConfig(ChannelProfile.CHANNEL_PROFILE_CHAT_ROOM, isAutoPublish, isAutoSubscribeAudio, isAutoSubscribeVideo);
// 加入房间
rtcRoom.joinRoom(token, userInfo, roomConfig);
}java// 创建房间
self.rtcRoom = self.rtcVideo?.createRTCRoom(roomId)
self.rtcRoom?.delegate = self
// 用户信息
let userInfo = ByteRTCUserInfo.init()
userInfo.userId = userId
// 房间配置
let roomCfg = ByteRTCRoomConfig.init()
// 加入房间
self.rtcRoom?.joinRoom(token, userInfo: userInfo, roomConfig: roomCfg)swift// 创建房间
bytertc::IRTCRoom *room = video->createRTCRoom(id);
// 加入房间
room->joinRoom(token, usrinfo, config);cpponRoomStateChanged 回调,进入 RTC 房间成功后调用 startPushMixedStreamToCDN。
private void startPushCDNStream() {
String cdnAddr = cdnAddressInput.getText().toString();
if (cdnAddr.isEmpty()) {
ToastUtil.showAlert(this, "cdn address is null");
return;
}
mixedStreamConfig.setUserID(localUid);
mixedStreamConfig.setRoomID(roomID);
// rtmp 推流地址
mixedStreamConfig.setPushURL(cdnAddr);
mixedStreamConfig.setExpectedMixingType(ByteRTCStreamMixingType.STREAM_MIXING_BY_SERVER);
MixedStreamConfig.MixedStreamLayoutConfig layoutConfig = new MixedStreamConfig.MixedStreamLayoutConfig();
// 背景色layoutConfig.setBackgroundColor(layoutColorInput.getText().toString());
// 设置合流布局
layoutConfig.setRegions(getLayoutRegions());
mixedStreamConfig.setLayout(layoutConfig);
// 开始推流到 CDN
rtcVideo.startPushMixedStreamToCDN(CDN_TASK_ID, mixedStreamConfig, mixedStreamObserver);
}java@objc func startPushCDN() {
// 设置合流参数
self.mixConfig = ByteRTCMixedStreamConfig.default()
// rtmp 推流地址
self.mixConfig?.pushURL = pushUrl
// 设置合流布局
self.mixConfig?.layoutConfig.regions = self.getMixRegions()
// 背景色
self.mixConfig?.layoutConfig.backgroundColor = "#FFFFFF"
self.mixConfig?.roomID = roomId!
self.mixConfig?.userID = userId!
self.rtcVideo?.startPushMixedStream(toCDN: taskId, mixedConfig: self.mixConfig!, observer: self)
}
// 监听任务回调
// MARK: ByteRTCMixedStreamObserver
func isSupportClientPushStream() -> Bool {
return false //应用层是否具备推流能力, false:不具备,使用RTC进行合流转推
}
// 合流事件回调
func onMixingEvent(_ event: ByteRTCStreamMixingEvent, taskId: String, error errorCode: ByteRTCStreamMixingErrorCode, mix mixType: ByteRTCMixedStreamType) {
ToastComponents.shared.show(withMessage: "onMixingEvent:(event.rawValue) taskId:(taskId) errorCode:(errorCode.rawValue) + mixType:(mixType.rawValue)")
}
swiftbytertc::IMixedStreamConfig* mixed_stream_param = getMixedStreamConfig();
video->startPushMixedStreamToCDN(m_task, mixed_stream_param, handler);
// 监听任务回调
class ByteRTCEventHandler :public bytertc::IMixedStreamObserver {
virtual bool isSupportClientPushStream() {
return false;//应用层是否具备推流能力, false:不具备,使用RTC进行合流转推
}
virtual void onMixingEvent(
bytertc::StreamMixingEvent event, const char* task_id, bytertc::StreamMixingErrorCode error, bytertc::MixedStreamType mix_type)
//使用RTC服务端合流时收到的事件回调
}
virtual void onMixingVideoFrame(const char* task_id, bytertc::IVideoFrame* video_frame) {
//使用RTC合流时,收到的视频帧数据
}
virtual void onMixingAudioFrame(const char* task_id, bytertc::IAudioFrame* audio_frame){
//使用RTC合流时,收到的音频帧
}
virtual void onMixingDataFrame(const char* task_id, bytertc::IDataFrame* data_frame){
//使用RTC合流时,收到的Data数据
}
cppprivate MixedStreamConfig.MixedStreamLayoutRegionConfig[] getLayoutRegions() {
int width = 360;
int height = 640;
int userNum = userNameList.size();
MixedStreamConfig.MixedStreamLayoutRegionConfig[] regions = new MixedStreamConfig.MixedStreamLayoutRegionConfig[userNum];
int index = 0;
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.setLocationX(index * width / userNum);
// 留出部分背景区域
region.setLocationY(50);
region.setWidth(width / userNum);
region.setHeight(height);
region.setAlpha(1);
region.setZOrder(0);
region.setRenderMode(MixedStreamConfig.MixedStreamRenderMode.MIXED_STREAM_RENDER_MODE_HIDDEN);
region.setStreamType(MixedStreamConfig.MixedStreamLayoutRegionConfig.MixedStreamVideoType.MIXED_STREAM_VIDEO_TYPE_MAIN);
region.setMediaType(MixedStreamConfig.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.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(MixedStreamConfig.MixedStreamRenderMode.MIXED_STREAM_RENDER_MODE_HIDDEN);
region.setStreamType(MixedStreamConfig.MixedStreamLayoutRegionConfig.MixedStreamVideoType.MIXED_STREAM_VIDEO_TYPE_MAIN);
region.setMediaType(MixedStreamConfig.MixedStreamMediaType.MIXED_STREAM_MEDIA_TYPE_AUDIO_AND_VIDEO);
regions[index] = region;
index ++;
}
}
return regions;
}javafunc 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
}swiftbytertc::IMixedStreamConfig* CDNStreamByServer::getMixedStreamConfig()
{
// audio
bytertc::MixedStreamAudioConfig audioParam;
audioParam.audio_codec = bytertc::MixedStreamAudioCodecType::kMixedStreamAudioCodecTypeAAC;
audioParam.channels = 2;
audioParam.bitrate = abitrate;
audioParam.sample_rate = 44100;
audioParam.audio_profile = bytertc::MixedStreamAudioProfile::kMixedStreamAudioProfileLC;
// video
bytertc::MixedStreamVideoConfig videoParam;
videoParam.bitrate = vbitrate;
videoParam.fps = vfps;
videoParam.gop = vgop;
videoParam.width = 640;
videoParam.height = 360;
videoParam.enable_Bframe = false;
videoParam.video_codec = bytertc::kMixedStreamVideoCodecTypeH264;
// layout
std:vector<bytertc::MixedStreamLayoutRegionConfig> layouts;
bytertc::MixedStreamLayoutRegionConfig lay;
lay.region_id = uid;
lay.room_id = roomid;
lay.location_x = 0.0;
lay.location_y = 0.0;
lay.width= 0.8*videoParam.width;
lay.height = 0.8*videoParam.height;
lay.is_local_user = bLocal;
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.push_back(lay);
bytertc::MixedStreamClientMixConfig client_conf;
client_conf.use_audio_mixer = true;
client_conf.video_format = bytertc::kMixedStreamClientMixVideoFormatI420;
bytertc::IMixedStreamConfig* mixed_stream_param = bytertc::createMixedStreamConfig();
mixed_stream_param->setAudioConfig(audioParam);
mixed_stream_param->setVideoConfig(videoParam);
mixed_stream_param->setLayoutConfig(layouts, m_rendered_users.size(), m_str_color.c_str(), nullptr);
mixed_stream_param->setExpectedMixingType(bytertc::kMixedStreamTypeByServer); //由RTC服务端进行合流转推
mixed_stream_param->setPushURL(url.c_str());
mixed_stream_param->setRoomID(roomid.c_str());
mixed_stream_param->setUserID(localid.c_str());
mixed_stream_param->setClientMixConfig(client_conf);
return mixed_stream_param;
}cpp在收到远端用户视频流后,才可以更新合流布局。
在转推直播进行时,部分设置可以更新,详见 API 文档。
private void updateCDNStreamConfig() {
String cdnAddr = cdnAddressInput.getText().toString();
if (cdnAddr.isEmpty()) {
ToastUtil.showAlert(this, "cdn address is null");
return;
}
mixedStreamConfig.setPushURL(cdnAddr);
MixedStreamConfig.MixedStreamLayoutConfig layoutConfig = new MixedStreamConfig.MixedStreamLayoutConfig();
layoutConfig.setBackgroundColor(layoutColorInput.getText().toString());
layoutConfig.setRegions(getLayoutRegions());
mixedStreamConfig.setLayout(layoutConfig);
rtcVideo.updatePushMixedStreamToCDN(CDN_TASK_ID, mixedStreamConfig);
}java@objc func updatePushConfig() {
self.rtcVideo?.updatePushMixedStream(toCDN: taskId, mixedConfig: self.mixConfig!)
}swiftbytertc::IMixedStreamConfig* config = getMixedStreamConfig();
int ret = video->updatePushMixedStreamToCDN(m_task.c_str(), config);cpp在音视频互动中,你可以随时启动或停止转推直播。
private void stopPushCDNStream() {
rtcVideo.stopPushStreamToCDN(CDN_TASK_ID);java@objc func stopPushCDN() {
self.rtcVideo?.stopPushStreamToCDN(taskId)swiftint ret = video->stopPushStreamToCDN(task);cppprivate void leaveRoom() {
if (rtcRoom != null) {
rtcRoom.leaveRoom();
rtcRoom.destroy();
rtcRoom = null;
}
}javaself?.rtcRoom?.leaveRoom()
self?.rtcRoom?.destroy()
self?.rtcRoom = nilswiftroom->leaveRoom();
room->destroyRTCRoom();
room = nullptr;cppRTCVideo.destroyRTCVideo();javaByteRTCVideo.destroyRTCVideo()
self.rtcVideo = nilswiftbytertc::destroyRTCVideo();
video = nullptr;cpp说明:表格中的 macOS API 接口为 Objective-C,而示例项目中的 macOS 项目使用的是 Windows SDK 中的 API 接口。
| API | Android | iOS | macOS | Windows | Electron | Fluter | Web |
|---|---|---|---|---|---|---|---|
开启转推 |
| 使用 |
|
|
|
| |
| 更改音视频参数和视频布局 | updatePushMixedStreamToCDN | updatePushMixedStreamToCDN:mixedConfig: | updatePushMixedStreamToCDN:mixedConfig: | updatePushMixedStreamToCDN | updatePushMixedStreamToCDN | updatePushMixedStreamToCDN | updateLiveTranscoding |
| 停止转推直播 | stopPushStreamToCDN | stopPushStreamToCDN: | stopPushStreamToCDN: | stopPushStreamToCDN | stopPushStreamToCDN | stopPushStreamToCDN | stopLiveTranscoding |
SEI 是视频编码格式中的补充增强信息,和视频编码帧一起打包发送,因此可以达到和视频帧发送和解析同步的效果。转推任务发起成功后,画面布局和背景等信息作为 SEI 透传到 RTMP 流中。拉流端需要自行提取和解析 SEI,例如,更新画面布局。
合流接口中传递到直播流中的信息,会在合流 I 帧前重复发送。例如,合流布局不变更,重复发送相同 SEI 数据,当合流布局变更,触发一个最新的 SEI。
在开启/更新合流时,可以通过设置 layoutConfig.userConfigExtraInfo 来设置自定义 SEI 信息。详见 [发送和接收媒体补充增强信息](70140.html)。比如在直播答题场景中,在 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
}
json当 APP 需要开启多个视频合流时,可以通过 task_id 来区分多个合流 ID。如果同时只有一个合流视频数据可以使用空字符串代替。startPushMixedStreamToCDN 和 stopPushStreamToCDN 的 task_id 需要成对出现。 如果 task_id 不同,会导致合流不会关闭。
在开启转推任务后,如果因为进行刷新页面等操作,造成应用端进程异常终止,则转推任务会在空闲时间超过设定值后自动停止,默认空闲超时时间为180s。
重启客户端重新登录后,需先调用 stopPushStreamToCDN 结束上一个任务,再开启新的转推任务,以免造成多个任务同时操作一个推流地址,导致新的转推任务失败。
转推直播过程中返回的错误码详见各端 API 文档。