前言
web端也能开启直播了,是的,就和OBS推流一样,可以捕捉浏览器窗口、桌面应用和桌面的画面流,可以捕捉麦克风、摄像头,可以推送到第三方直播平台,类似于B站、抖音等,可以设置推流分辨率和码率。
这个功能B站已经有了web在线直播,我们做的这个项目也是根据公司的需求,参考B站的功能实现的,所以这里不提供源码,只分享技术实现。
功能介绍
直播间画面预览
开启直播前需要预览直播画面,并进行子画面布局、文字大小等细节的调整,之后将预览画面中的内容实时直播给观众
游戏画面捕捉
捕获本地电脑游戏应用的画面,将画面加入直播间预览窗口中,后续开启直播的实时画面与预览窗口中的画面一致
麦克风
捕获本地麦克风(音频输入设备),直播时可以同步讲解和与观众互动
摄像头
捕获本地摄像头,将摄像头画面加入到预览窗口中
画面素材:文字、图片、视频
可以在直播画面中加入文字、图片和视频,可以用来打广告或者遮挡游戏画面中的敏感部分
开启直播
将实时画面推送到第三方直播平台,观众可以在平台直播间观看直播
关闭直播
关闭游戏直播推流
实现方案
直播间画面预览
直播间画面预览采用的fabric.js实现的,fabric.js
可以绘制视频流,对绘制上去的元素进行拖拽、缩放、层级修改等操作,方便主播展示更好的画面内容。
html内容
html
// 设置好canvas的宽高(1920*1080)和id
<canvas ref="canvasRef" id="canvasId"></canvas>
js内容
js
// 项目使用vue开发,this.fabricCanvas在后续代码中会继续使用
this.fabricCanvas = new fabric.Canvas('canvasId', {
width: 1920, // 画布的宽度
height: 1080, // 画布的高度
backgroundColor: '#2D3043', // 画布的背景颜色
preserveObjectStacking: true,
selection: false, // 设置画布不可选择
});
// 一直重复渲染,让视频画面能够播放
this.currentMaxFramerate = 60;
const render = () => {
this.fabricCanvas.renderAll();
// 当currentMaxFramerate等于20,实际fps是17.68
const delay = 1000 / (this.currentMaxFramerate / (17.68 / 20)); // 60帧的话即16.666666666666668
this.animationFrameInterval = workerTimers.setInterval(() => {
this.fabricCanvas.renderAll();
}, delay);
// window.requestAnimationFrame(render);
};
render();
调用new fabric.Canvas
方法并指定一个canvas
的id,便可以将后续的直播素材画面绘制到fabric.js
控制的画布上。
想要fabric上出现动态的画面,就需要一直调用renderAll()
方法让画面刷新,workerTimers
是引入的worker-timers
库,采用webwoker的技术,让可以保持窗口最小化后还是在执行的状态(window.requestAnimationFrame和setInterval在浏览器窗口最小化后是不执行的)
游戏画面捕捉
通过调用navigator.mediaDevices.getDisplayMedia(constraints)
方法可以唤起浏览器窗口捕捉功能,constraints
为捕捉窗口时相应参数
js
const constraints = {
video: {
frameRate: 30, // 捕捉桌面窗口画面的流的帧率
width: 1920, // 捕捉桌面窗口画面的流的宽度
height: 1080, // 捕捉桌面窗口画面的流的高度
},
audio: {
echoCancellation: true, // 控制是否启用回声消除
noiseSuppression: true, // 是否尝试去除音频信号中的背景噪声
autoGainControl: true, // 是否要修改麦克风的输入音量
},
};
navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
console.log('捕捉到的桌面窗口画面的流:', stream);
const video = document.createElement('video');
video.muted = true;
video.autoplay = true;
video.srcObject = stream; // 指定video标签播放窗口画面流 stream
video.oncanplay = () => {
// 捕获到的流的宽度和高度
const videoStreamWidth = video.videoWidth;
const videoStreamHeight = video.videoHeight;
video.width = videoStreamWidth;
video.height = videoStreamHeight;
// 将窗口视频流的video加入到fabric中
const fabricVideo = new fabric.Image(video, {
angle: 0, // 旋转角度
top: 0, // 距离顶部距离
left: 0, // 距离左边距离
cornerSize: 8, // 元素在fabric中被选中时边框控制器的大小
// 通过缩放比例控制元素的大小,方便在不同电脑不同浏览器上开播时画面的显示效果一致
scaleX: videoStreamWidth / video.videoWidth,
scaleY: videoStreamHeight / video.videoHeight,
});
this.fabricCanvas.add(fabricVideo); // 将元素添加到fabric画布中
fabricVideo.moveTo(0); // 将元素移动到指定层级
// 存储素材的原始数据,后续编辑素材、同步素材时都要从这个数据里面取值,保证数据的一致性
this.materialList.push({
type: materialMaps.desktop,
uuid: uuid,
name: name,
stream: stream,
domEl: video,
fabricItem: fabricVideo,
materialInfo: desktop,
selected: false,
});
// 开播状态下音频流数据发生变动后,处理直播流的实时变动
this.handleMixedAudio();
};
});
获取到窗口的流之后,创建一个video标签播放窗口流,并使用new fabric.Image
方法将video的画面渲染到fabric画布上去(this.fabricCanvas.add(fabricVideo)),并且要存储好所有原始数据,后续的开播、编辑、数据同步等功能,都需要从原始数据里去值,才能保证数据的一致性
上面代码中有些字段从我们自己项目代码中直接复制过来的,如果你们测试时可以直接替换成你们实际的数据
navigator.mediaDevices.getDisplayMedia(constraints)
可以捕获浏览器页签、桌面应用窗口和整个屏幕,主播可以自行选择想要捕获的内容。
麦克风
通过调用navigator.mediaDevices.getUserMedia(constraints)
方法可以获取系统麦克风,获取到的是音频录入设备的列表,可以自己选择使用哪个麦克风
js
const constraints = {
audio: true, // 获取麦克风权限
};
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
// 因为音频没有画面,且预览时主播自己不用听到自己的声音,所以只用存储原始数据就醒
const audio = document.createElement('audio');
// audio.autoplay = true;
audio.srcObject = stream;
// 存储素材的原始数据
this.materialList.push({
type: materialMaps.microphone,
uuid: uuid,
name: name,
stream: stream,
domEl: audio,
materialInfo: microphone,
selected: false,
});
// 音频流数据发生变动后如果在直播中,处理直播流的实时变动
this.handleMixedAudio();
});
音频流后续需要与视频流进行合流一起推送给直播平台才能正常播放
摄像头
摄像头同样是通过调用navigator.mediaDevices.getUserMedia(constraints)
方法获取,如果电脑接入了多个视频录入设备,同样获取到的也是一个列表,可以自己选择使用哪个摄像头,数据处理逻辑和捕捉桌面流程差不多
js
const constraints = {
video: {
width: cameraWidth,
height: cameraHeight,
},
};
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
const video = document.createElement('video');
// 设置video的属性
video.width = cameraWidth;
video.height = cameraHeight;
video.autoplay = true;
video.srcObject = stream;
// 将窗口视频流的video加入到fabric中
const fabricVideo = new fabric.Image(video, {
uuid: uuid,
angle: angle,
top: top,
left: left,
cornerSize: cornerSize,
});
this.fabricCanvas.add(fabricVideo);
fabricVideo.moveTo(index);
// 存储素材的原始数据
this.materialList.push({
type: materialMaps.camera,
uuid: uuid,
name: name,
stream: stream,
domEl: video,
fabricItem: fabricVideo,
materialInfo: camera,
selected: false,
});
});
画面素材:文字、图片、视频
给画面添加文字、图片、视频等功能,需要使用fabric完成,后续直接获取fabric的canvas的流便可获取到对应的画面内容
添加文字:fabric.IText
添加图片:fabric.Image
添加视频:fabric.Image
,添加视频和上面捕获桌面流之后的操作是一样的,同样是通过fabric.Image
将视频一帧一帧的绘制展示
开启直播
开启直播需要借助ZLMediaKit进行转发,所以我们要先搭建好ZLMediaKit的服务,搭建好ZLMediaKit的服务之后,通过WebRTC传输流,调用ZLMediaKit的api进行开播操作
步骤一: 建立webrtc的连接
直播画面的流直接通过canvas的captureStream方法从fabric画布中获取,音频从我们存储的原始数据中遍历获取,将音频合流后才能加入webrtc中。
js
/** 初始化peer */
this.peer = new RTCPeerConnection();
this.peer.onicecandidate = this.handlePCOnIcecandidate;
this.peer.ontrack = this.handlePCOnTranck;
this.localStream = this.$refs.canvasRef.captureStream(60); // 获取fabric画布中的所有画面合并之后的流
// 将视频流添加到peer中去
this.localStream.getTracks().forEach(track => {
this.peer.addTrack(track, this.localStream);
});
// 设置码率、分辨率
this.setSenderParamerters({ codeRate: this.codeRate, resolutionRate: this.resolutionRate });
this.audioCtx = new AudioContext();
this.destination = this.audioCtx.createMediaStreamDestination();
// 进行音频流合流
this.handleMixedAudio(true);
// 获取destination的音频轨道
this.audioTrack = this.destination.stream.getAudioTracks()[0];
this.localStream.addTrack(this.audioTrack);
// 将音频加入到peer中去
this.peer.addTrack(this.audioTrack, this.localStream);
音频合流代码
js
/** 调整音频流的音量大小并将所有音频流混流 */
handleMixedAudio(flag) {
if (flag || (this.isPushing && this.localStream)) {
const res = [];
// 遍历所有素材,将所有音频流加入到destination中去
this.materialList.forEach(ele => {
if (this.audioCtx && this.hasAudioStream(ele)) {
try {
// 创建Source
const source = this.audioCtx.createMediaStreamSource(ele.stream);
source.type = 'audio/ogg; codecs=opus';
// 创建音量控制
const gainNode = this.audioCtx.createGain();
gainNode.gain.value = (ele.materialInfo.volume || 100) / 100;
source.connect(gainNode);
res.push({ source, gainNode });
} catch (error) {}
}
});
// 所有声音通过destination输出
res.forEach(item => {
item.source.connect(item.gainNode);
item.gainNode.connect(this.destination);
});
}
}
为了保证直播画面的清晰度,或者满足观众根据自己网络情况选择清晰度(流程、标清、超清、蓝光),我们需要对webrtc传输的流的分辨率和码率进行调整,调整分辨率和码率的方法可以看webrtc视频流设置码率、分辨率和帧率
调用/index/api/webrtc
接口获取媒体协商和网络协商的数据,(webrtc相关知识可以看快速学会WebRTC连接,实现实时音视频通讯),在该接口返回成功后执行步骤二
步骤二: 准备好相关参数,调用/index/api/addFFmpegSource
接口将流推送到指定直播间
通过dst_url可以指定需要推送的第三方直播平台的直播间,以B站为例,dst_url为开播设置中的 服务器地址+串流秘钥
之所以使用addFFmpegSource接口而不使用addStreamPusherProxy是因为音频编码格式问题,只有addFFmpegSource接口进行转发的音频编码格式在直播中才能正确的被播放。
关闭直播
关闭直播调用/index/api/delFFmpegSource
接口即可,并自己处理好关闭直播后的代码逻辑。
结语
其实现在前端有各种api和工具支持我们对音视频和音视频流进行处理和传输,如WebCodecs、WebAssembly等,我们在处理前端直播的流时或许可以使用更优的方式进行处理,比如用WebCodecs进行合流,修改音频编码格式等,后续我会再研究学习下。
关于Web端开启直播的技术分享就到这里了,有任何疑问大家都可以评论区问我。