背景
近几年直播行业飞速发展,但是由于 Web 端这方面功能的长时间缺失,使得直播端以客户端为主;WebRTC 的出现使得网页也可以成为直播端。那么究竟 WebRTC 是什么呢?
WebRTC,即 Web Real-Time Communication,web 实时通信技术。简单地说就是在 web 浏览器里面引入实时通信,包括音视频通话等,它使得实时通信变成一种标准功能,任何 Web 应用都无需借助第三方插件和专有软件,而是通过 JavaScript API 即可完成;而且 WebRTC 提供了视频会议的核心技术,包括音视频的采集、编解码、网络传输、展示等功能,还支持跨平台,包括主流的 PC 和移动端设备。
下面介绍下需要用到的几个 API:
getUserMedia
我们可以通过调用 navigator.mediaDevices.getUserMedia(constraints) 去初始化一个本地的音视频流,然后把直播流通过 video 标签播放。代码如下:
html:
<div id="container">
<video id="gum-local" autoplay playsinline></video>
<button id="showVideo">Open camera</button>
<button id="switchVideo">switch camera</button>
</div>
js:
const constraints = {
audio: false,
video: true
};
async function init(e) {
try {const stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.querySelector('video');
video.srcObject = stream;
} catch (e) {console.log(e, 'stream init error');
}
}
document.querySelector('#showVideo').addEventListener('click', (e) => init(e));
示例效果:
当然,如果有多个设备,就需要考虑设备选择和设备切换的问题。那就需要用到下面的这个 API。
设备
我们看看如何用原生的 Web API 去获取设备(以下示例代码可适用于 Chrome,其他浏览器暂未测试;具体浏览器兼容性可参考官方文档,本文档底部有链接)。
navigator.mediaDevices.enumerateDevices()
如果枚举成功将会返回一个包含 MediaDeviceInfo 实例的数组,它包含了可用的多媒体输入输出设备的信息。
下面是调用代码示例。
navigator.mediaDevices.enumerateDevices().then((devices) => {console.log(devices, '-----enumerateDevices------');
});
设备参数说明:
- deviceId:设备 id,具有唯一性
- groupId:设备组 id,不具有唯一性
- kind:设备类别(audioinput:音频输入设备,audiooutput:音频输出设备,videoinput:视频输入设备)
- label:设备名称(未经过授权允许的设备,label 值为空,授权允许后可拿到 label 的值,如下两图所示)
获取的所有设备截图(未授权):
videoinput 已授权截图:
获取到设备列表后,可设置 navigator.mediaDevices.getUserMedia(constraints) 的 constraints 参数选择所用设备。
const {audioList, videoList} = await getDevices();
const constraints = {
audio: {deviceId: audioList[0].deviceId
},
video: {deviceId: videoList[0].deviceId
}
};
navigator.mediaDevices.getUserMedia(constraints);
...
然而,我们在更换 deviceId 切换设备的时候发现一些异常情况。在某些 deviceId 之间切换时,摄像头画面或者是麦克风采集处并没有发生变化。进一步调试发现,这些切换后没有发生变化的 deviceId 都具有相同的 groupId。因此,相同 groupId 下的设备,选择一个用于切换即可。
筛选麦克风、摄像头设备示例:
function getDevices() {return new Promise((resolve) => {navigator.mediaDevices.enumerateDevices().then((devices) => {const audioGroup = {};
const videoGroup = {};
const cameraList = [];
const micList = [];
devices.forEach((device, index) => {if ((!device.groupId || !audioGroup[device.groupId]) && device.kind === 'audioinput') {micList.push(device);
audioGroup[device.groupId] = true;
}
if ((!device.groupId || !videoGroup[device.groupId]) && device.kind === 'videoinput') {cameraList.push(device);
videoGroup[device.groupId] = true;
}
});
resolve({cameraList, micList});
});
});
}
注意:在 Chrome 下,电脑外接摄像头后拔出设备,此时还有可能获取到拔出的设备信息,在进行切换的时候会有问题,可以采用在页面进行友好提示处理这种情况。
屏幕共享
MediaDevices.getDisplayMedia
Chrome 72+、Firefox 66+ 版本已经实现了 WebRTC 规范中的 MediaDevices.getDisplayMedia,具备屏幕共享功能。
navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
}).then(stream => {video.srcObject = stream;}).catch(err => {console.error(err);
});
示例效果:
对于 Chrome 72 以下的版本,想要实现屏幕共享的功能需要借助 Chrome 插件去获取 screen(显示器屏幕)、application windows(应用窗口) 和 browser tabs(浏览器标签页)。Chrome 插件:由 manifest.json 和 script.js 组成。
manifest.json 填入一些基本数据。background 中 scripts 传入需执行的 js 文件。添加 permissions: ['desktopCapture'],用来开启屏幕共享的权限。externally_connectable 用来声明哪些应用和网页可以通过 `runtime.connect` 和 `runtime.sendMessage` 连接到插件。{
"manifest_version": 2,
"name": "Polyv Web Screensharing",
"permissions": ["desktopCapture"],
"version": "0.0.1",
"background": {
"persistent": false,
"scripts": ["script.js"]
},
"externally_connectable": {"matches": ["*://localhost:*/*"]
}
}
script.js
// script.js
chrome.runtime.onMessageExternal.addListener(function(request, sender, sendResponse) {if (request.getStream) {
// Gets chrome media stream token and returns it in the response.
chrome.desktopCapture.chooseDesktopMedia(['screen', 'window', 'tab'], sender.tab,
function(streamId) {sendResponse({ streamId: streamId});
});
return true; // Preserve sendResponse for future use
}
}
);
在页面中开始屏幕共享。通过 chrome.runtime.sendMessage 发送消息到 Chrome 插件调起屏幕共享。获取到 streamId 后,通过 mediaDevices.getUserMedia 得到 stream。
const EXTENSION_ID = '<EXTENSION_ID>';
const video = $('#videoId');
chrome.runtime.sendMessage(EXTENSION_ID, { getStream: true}, res => {console.log('res:', res);
if (res.streamId) {
navigator.mediaDevices.getUserMedia({
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: res.streamId
}
}
}).then((stream) => {
video.srcObject = stream;
video.onloadedmetadata = function(e) {video.play();
};
})
} else {// 取消选择}
});
而 Firefox 66 版本以下,不需要像 Chrome 借助插件才能实现屏幕共享。Firefox 33 之后可以直接通过使用 mediaDevices.getUserMedia,指定约束对象 mediaSource 为 screen、window、application 来实现屏幕共享。不过在 Firefox 中,一次只能指定一种 mediaSource。
navigator.mediaDevices.getUserMedia({
video: {mediaSource: 'window'}
}).then(stream => {video.srcObject = stream;});
传输
WebRTC 的 RTCPeerConnection 可以建立点对点连接通信,RTCDataChannel 提供了数据通信的能力。
WebRTC 的点对点连接的过程为:
- 呼叫端给接收端发送一个 offer 信息。在发送给接收端之前先调用 setLocalDescription 存储本地 offer 描述。
- 接收端收到 offer 消息后,先调用 setRemoteDescription 存储远端 offer,再创建一个 answer 信息给呼叫端。
RTCDataChannel 提供了 send 方法和 message 事件。使用起来与 WebSocket 类似。
由于没有服务器,以下代码为呼叫端和接收端在同一页面上,RTCPeerConnection 对象之间是如何进行数据交互。
// 创建数据通道
sendChannel = localConnection.createDataChannel('通道名称', options);
sendChannel.binaryType = 'arraybuffer';
sendChannel.onopen = function() {sendChannel.send('Hi there!');
};
sendChannel.onmessage = function(evt) {console.log('send channel onmessage:', evt.data);
};
// 远端接收实例
remoteConnection = new RTCPeerConnection(servers);
remoteConnection.onicecandidate = function(evt) {if (evt.candidate) {localConnection.addIceCandidate(new RTCIceCandidate(evt.candidate));
}
};
// 当一个 RTC 数据通道已被远端调用 createDataChannel() 添加到连接中时触发
remoteConnection.ondatachannel = function() {
const receiveChannel = event.channel;
receiveChannel.binaryType = 'arraybuffer';
// 接收到数据时触发
receiveChannel.onmessage = function(evt) {console.log('onmessage', evt.data); // log: Hi there!
};
receiveChannel.send('Nice!');
};
// 监听是否有媒体流
remoteConnection.onaddstream = function(e) {peerVideo.srcObject = e.stream;};
localConnection.addStream(stream);
// 创建呼叫实例
localConnection.createOffer().then(offer => {localConnection.setLocalDescription(offer);
remoteConnection.setRemoteDescription(offer);
remoteConnection.createAnswer().then(answer => {remoteConnection.setLocalDescription(answer);
// 接收到 answer
localConnection.setRemoteDescription(answer);
})
});
至此我们已经介绍完毕浏览器设备检测采集和屏幕分享的基本流程,但是光有这些可还远远不够,一套完整的直播体系包括音视频采集、处理、编码和封装、推流到服务器、服务器流分发、播放器流播放等等。如果想节省开发成本,可以使用第三方 SDK。下面简单介绍下使用声网 SDK 发起直播的流程。
浏览器要求:
- Chrome 58+
- Firefox 56+
- Safari 11+(屏幕共享不可用)
- Opera 45+(屏幕共享不可用)
- QQ 10+(屏幕共享不可用)
- 360 安全浏览器 9.1+(屏幕共享不可用)
设备检测
调用 AgoraRTC.getDevices 获取当前浏览器检测到的所有可枚举设备,kind 为 ’videoinput’ 是摄像头设备,kind 为 ’audioinput’ 是麦克风设备,然后通过 createStream 初始化一个本地的流。获取设备:
AgoraRTC.getDevices((devices) => {const audioGroup = {};
const videoGroup = {};
const cameraList = [];
const micList = [];
devices.forEach((device, index) => {if ((!device.groupId || !audioGroup[device.groupId]) && device.kind === 'audioinput') {micList.push(device);
audioGroup[device.groupId] = true;
}
if ((!device.groupId || !videoGroup[device.groupId]) && device.kind === 'videoinput') {cameraList.push(device);
videoGroup[device.groupId] = true;
}
});
return {cameraList, micList};
});
初始化本地流:
// uid: 自定义频道号,cameraId 设备 Id
const stream = AgoraRTC.createStream({
streamID: uid,
audio: false,
video: true,
cameraId: cameraId,
microphoneId: microphoneId
});
stream.init(() => {
// clientCamera <div id="clientCamera" ></div>
stream.play('clientCamera', { muted: true});
}, err => {console.error('AgoraRTC client init failed', err);
});
stream.init() 初始化直播流;如果当前浏览器摄像头权限为禁止,则调用失败,可捕获报错 Media access NotAllowedError: Permission denied;若摄像头权限为询问,浏览器默认弹窗是否允许使用摄像头,允许后调用 play() 可看到摄像头捕获的画面。如果不传入 cameraId,SDK 会默认获取到设备的 deviceId, 如果权限是允许,同样会显示摄像头画面。
采集
摄像头
顺利拿到 cameraId 和 microphoneId 后就可以进行直播。通过 SDK 提供的 createStream 创建一个音视频流对象。执行 init 方法初始化成功之后,播放音视频 (见上文)。最后通过 client 发布流以及推流到 CDN(见下文)。
屏幕共享
Web 端屏幕共享,通过创建一个屏幕共享的流来实现的。Chrome 屏幕共享需要下载插件,在创建的流的时候还需要传入插件的 extensionId。
const screenStream = AgoraRTC.createStream({
streamID: <uid>,
audio: false,
video: false,
screen: true,
extensionId: <extensionId>, // Chrome 插件 id
mediaSource: 'screen' // Firefox
});
传输
通过 AgoraRTC.createStream 创建的音视频流,通过 publish 发送到第三方服务商的 SD-RTN(软件定义实时传输网络)。
client.publish(screenStream, err => {console.error(err);
});
别的浏览器可以通过监听到 stream-added 事件,通过 subscribe 订阅远端音视频流。
client.on('stream-added', evt => {
const stream = evt.stream;
client.subscribe(stream, err => {console.error(err);
});
});
再通过 startLiveStreaming 推流到 CDN。
// 编码
client.setLiveTranscoding(<coding>);
client.startLiveStreaming(<url>, true)
在推摄像头流的时候,关闭摄像头,需要推一张占位图。这个时候先用 canvas 画图,然后用 WebRTC 提供的 captureStream 捕获静态帧。再调用 getVideoTracks,制定 AgoraRTC.createStream 的 videoSource 为该值。视频源如来自 canvas,需要在 canvas 内容不变时,每隔 1 秒重新绘制 canvas 内容,以保持视频流的正常发布。
const canvas = document.createElement('canvas');
renderCanvas(canvas);
setInterval(() => {renderCanvas(canvas);
}, 1000);
canvasStream = canvas.captureStream();
const picStream = AgoraRTC.createStream({
streamID: <uid>,
video: true,
audio: false,
videoSource: canvasStream.getVideoTracks()[0]
});
// 画图
function renderCanvas(canvas) {...}
一个 client 只能推一个流,所以在进行屏幕共享的时候,需要创建两个 client,一个发送屏幕共享流,一个发送视频流。屏幕共享流的 video 字段设为 false。视频流的 video 字段设为 true。然后先通过 setLiveTranscoding 合图再推流。
const users = [
{
x: 0, // 视频帧左上角的横轴位置,默认为 0
y: 0, // 视频帧左上角的纵轴位置,默认为 0
width: 1280, // 视频帧宽度,默认为 640
height: 720, // 视频帧高度,默认为 360
zOrder: 0, // 视频帧所处层数;取值范围为 [0,100];默认值为 0,表示该区域图像位于最下层
alpha: 1.0, // 视频帧的透明度,默认值为 1.0
uid: 888888, // 旁路推流的用户 ID
},
{
x: 0,
y: 0,
width: 1280,
height: 720,
zOrder: 1,
alpha: 1.0,
uid: 999999
}
];
var liveTranscoding = {
width: 640,
height: 360,
videoBitrate: 400,
videoFramerate: 15,
lowLatency: false,
audioSampleRate: AgoraRTC.AUDIO_SAMPLE_RATE_48000,
audioBitrate: 48,
audioChannels: 1,
videoGop: 30,
videoCodecProfile: AgoraRTC.VIDEO_CODEC_PROFILE_HIGH,
userCount: user.length,
backgroundColor: 0x000000,
transcodingUsers: users,
};
client.setLiveTranscoding(liveTranscoding);
因为业务需求是摄像头和屏幕共享可以切换,摄像头和屏幕共享的分辨率和码率均不相同,屏幕共享需要更高的分辨率和码率。但是开发中发现切换时设置码率无效。SDK 那边给的答复是:因为缓存问题,会以第一次推流设置的参数为准,将会在下个版本中修复。
参考文献:
MediaDevices.getUserMedia()
MedaiDevices.enumerateDevices()
HTMLMediaElement
MediaDevices/getDisplayMedia