对于一个音视频通话,你可以将其中的多路音视频流合为一路,并将合并得到的音视频流推送到指定的推流地址(通常是 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();
创建房间实例后,你可以加入房间发布和订阅音视频流。建议设置房间回调接口,以便监听房间和音视频流的状态回调。
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); }
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.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); } // 监听任务回调 IMixedStreamObserver mixedStreamObserver = new IMixedStreamObserver() { @Override public boolean isSupportClientPushStream() { ToastUtil.showShortToast(CDNStreamActivity.this, "isSupportClientPushStream"); //应用层是否具备推流能力, false:不具备,使用 RTC 进行合流转推 return false; } @Override public void onMixingEvent(ByteRTCStreamMixingEvent eventType, String taskId, ByteRTCTranscoderErrorCode error, MixedStreamType mixType) { String msg = String.format("onMixingEvent, type:%s, taskId:%s, error:%s, mixType:%s", eventType.toString(), taskId, error.toString(), mixType.toString()); Log.d(TAG, msg); ToastUtil.showLongToast(CDNStreamActivity.this, msg); } @Override public void onMixingAudioFrame(String taskId, byte[] audioFrame, int frameNum, long timeStampMs) { String msg = String.format("onMixingEvent, taskId:%s, frameNum:%d, timeStampMs:%l", taskId, frameNum, timeStampMs); Log.d(TAG, msg); ToastUtil.showLongToast(CDNStreamActivity.this, msg); } @Override public void onMixingVideoFrame(String taskId, VideoFrame videoFrame) { String msg = String.format("onMixingVideoFrame, taskId:%s", taskId); Log.d(TAG, msg); ToastUtil.showLongToast(CDNStreamActivity.this, msg); } @Override public void onMixingDataFrame(String taskId, byte[] dataFrame, long time) { String msg = String.format("onMixingDataFrame, taskId:%s", taskId); Log.d(TAG, msg); ToastUtil.showLongToast(CDNStreamActivity.this, msg); } @Override public void onCacheSyncVideoFrames(String taskId, String[] userIds, VideoFrame[] videoFrame, byte[][] dataFrame, int count) { String msg = String.format("onCacheSyncVideoFrames, taskId:%s", taskId); Log.d(TAG, msg); ToastUtil.showLongToast(CDNStreamActivity.this, msg); } };
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; }
在收到远端用户视频流后,才可以更新合流布局。
在合流转推进行时,部分设置可以更新,详见 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); }
在音视频互动中,你可以随时启动或停止合流转推。
private void stopPushCDNStream() { rtcVideo.stopPushStreamToCDN(CDN_TASK_ID); }
private void leaveRoom() { if (rtcRoom != null) { rtcRoom.leaveRoom(); rtcRoom.destroy(); rtcRoom = null; } }
RTCVideo.destroyRTCVideo();
说明:表格中的 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 信息。详见 发送和接收媒体补充增强信息。比如在直播答题场景中,在 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 }
当 APP 需要开启多个视频合流时,可以通过 task_id 来区分多个合流 ID。如果同时只有一个合流视频数据可以使用空字符串代替。startPushMixedStreamToCDN
和 stopPushStreamToCDN
的 task_id
需要成对出现。 如果 task_id
不同,会导致合流不会关闭。
在开启转推任务后,如果因为进行刷新页面等操作,造成应用端进程异常终止,则转推任务会在空闲时间超过设定值后自动停止,默认空闲超时时间为180s
。
重启客户端重新登录后,需先调用 stopPushStreamToCDN
结束上一个任务,再开启新的转推任务,以免造成多个任务同时操作一个推流地址,导致新的转推任务失败。
合流转推过程中返回的错误码详见各端 API 文档。