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

alt

关于如何调用 Open API,在服务端完成转推直播,参见 通过 OpenAPI 使用转推直播功能。

前提条件

你已经集成 RTC SDK,实现了基本的音视频通话。
支持发起转推直播的 SDK 详见API 及回调。

调用时序

api

1. 创建引擎类

创建和初始化一个音视频引擎类。

参考 构建 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
2. 进房

创建房间实例后,你可以加入房间发布和订阅音视频流。建议设置房间回调接口,以便监听房间和音视频流的状态回调。

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);
cpp

3. 开启任务
  1. 发起转推直播任务,在收到 onRoomStateChanged 回调,进入 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)")
    }
swift
bytertc::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数据
}


cpp
  1. 合流视频的布局设置。分别以 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;
    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;
}
java
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
}
swift
bytertc::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
4. 更新任务

在收到远端用户视频流后,才可以更新合流布局。
在转推直播进行时,部分设置可以更新,详见 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!)
}
swift
bytertc::IMixedStreamConfig* config = getMixedStreamConfig();
int ret = video->updatePushMixedStreamToCDN(m_task.c_str(), config);
cpp
5. 结束任务

在音视频互动中,你可以随时启动或停止转推直播。

private void stopPushCDNStream() {
    rtcVideo.stopPushStreamToCDN(CDN_TASK_ID);
java
@objc func stopPushCDN()  {
    self.rtcVideo?.stopPushStreamToCDN(taskId)
swift
int ret = video->stopPushStreamToCDN(task);
cpp
6. 离房
private void leaveRoom() {
    if (rtcRoom != null) {
        rtcRoom.leaveRoom();
        rtcRoom.destroy();
        rtcRoom = null;
    }
}
java
self?.rtcRoom?.leaveRoom()
self?.rtcRoom?.destroy()
self?.rtcRoom = nil
swift
room->leaveRoom();
room->destroyRTCRoom();
room = nullptr;
cpp
7. 销毁引擎
RTCVideo.destroyRTCVideo();
java
ByteRTCVideo.destroyRTCVideo()
self.rtcVideo = nil
swift
bytertc::destroyRTCVideo();
video = nullptr;
cpp
示例项目

API 及回调

说明:表格中的 macOS API 接口为 Objective-C,而示例项目中的 macOS 项目使用的是 Windows SDK 中的 API 接口。

APIAndroidiOSmacOSWindowsElectronFluterWeb

开启转推

startPushMixedStreamToCDN
任务参数为 MixedStreamConfig 结构体
使用 defaultMixedStreamConfig 进行参数初始化。

使用 startPushMixedStreamToCDN:mixedConfig:observer:
任务参数为 ByteRTCMixedStreamConfig 结构体,
使用 defaultMixedStreamConfig 进行参数初始化。

startPushMixedStreamToCDN:mixedConfig:observer: 开启转推。
任务参数为 ByteRTCMixedStreamConfig 结构体,
使用 defaultMixedStreamConfig 进行参数初始化。

startPushMixedStreamToCDN
任务参数为 IMixedStreamConfig 结构体,
使用 defaultMixedStreamConfig 进行参数初始化。

startPushMixedStreamToCDN
任务参数为 IMixedStreamConfig 结构体

startPushMixedStreamToCDN
任务参数为 MixedStreamConfig 结构体

startLiveTranscoding

更改音视频参数和视频布局updatePushMixedStreamToCDNupdatePushMixedStreamToCDN:mixedConfig:updatePushMixedStreamToCDN:mixedConfig:updatePushMixedStreamToCDNupdatePushMixedStreamToCDNupdatePushMixedStreamToCDNupdateLiveTranscoding
停止转推直播stopPushStreamToCDNstopPushStreamToCDN:stopPushStreamToCDN:stopPushStreamToCDNstopPushStreamToCDNstopPushStreamToCDNstopLiveTranscoding
常见问题

1. 如何在转推直播流中使用 SEI

SEI 是视频编码格式中的补充增强信息,和视频编码帧一起打包发送,因此可以达到和视频帧发送和解析同步的效果。转推任务发起成功后,画面布局和背景等信息作为 SEI 透传到 RTMP 流中。拉流端需要自行提取和解析 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
2. 如何设置 task_id

当 APP 需要开启多个视频合流时,可以通过 task_id 来区分多个合流 ID。如果同时只有一个合流视频数据可以使用空字符串代替。
startPushMixedStreamToCDN 和 stopPushStreamToCDN 的 task_id 需要成对出现。 如果 task_id 不同,会导致合流不会关闭。

3. 如何处理发起端意外掉线后重新登录

在开启转推任务后,如果因为进行刷新页面等操作,造成应用端进程异常终止,则转推任务会在空闲时间超过设定值后自动停止,默认空闲超时时间为180s。
重启客户端重新登录后,需先调用 stopPushStreamToCDN 结束上一个任务,再开启新的转推任务,以免造成多个任务同时操作一个推流地址,导致新的转推任务失败。

4. 错误码

转推直播过程中返回的错误码详见各端 API 文档。

AndroidiOSMacWindowsElectronFlutterWeb
ByteRTCTranscoderErrorCodeByteRTCStreamMixingErrorCodeByteRTCStreamMixingErrorCodeStreamMixingErrorCodeStreamMixingErrorCodeStreamMixingErrorCodeStreamMixingEventErrorCode