点到点的视频通信 本文主要是使用了一只斌师傅的 webrtc 教程完完整整的复现来的,链接如下:
https://www.bilibili.com/video/BV1py4y1e7G6/?spm_id_from=333.999.0.0&vd_source=270df07e19daab36f36e1863f3440455
目的 实现点对点的视频通信,原本的实验要求如下:
(1)发送端能够正确捕获视频、接收端正确显示视频; (2)视频需要选用一种编码方式 (3)可选要求:使用RTP/RTCP监测视频流状态、同时传输音频
但是经过查找资料发现,现在主流的视频通信大都用 webrtc 实现,于是自学了 webrtc 最终做出来一个视频通信的 demo。
webRTC 基本概念 MDN(Mozilla Developer Network)对WebRTC的定义:WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
注: 引用自https://juejin.cn/post/7071994793710075911
架构
可以看到,一个简单的点对点通讯系统主要由四部分组成:
WebRTC客户端:负责生产/消费音视频数据,位于NAT之内,属于内网
NAT:Network Address Translation (NAT),网络地址转换协议, 能够将设备的内网地址映射到一个公网的地址。
信令服务器:用于传输SDP、candidate等信令数据。
STUN/TURN服务器(中继服务器):
STUN:用于为位于NAT内的设备找到自己的公网地址。WebRTC客户端通过给处于公网的STUN服务器发送请求来获得自己的公网地址信息,以及是否能够被(穿过路由器)访问。
TURN:对于无法通过STUN服务器进行内网穿越的“对称型NAT”,我们可以借助TURN服务器作为中继服务器,通过TURN服务器对数据进行转发。
点对点的通信原理:
首先客户端需要信令服务器连接,后续双方需要通过信令服务器来了解对方的一些必要的信息,比如告诉对方自己的支持的音视频格式、自己外网IP地址和端口是多少等 (此时还无法知道自己的公网地址)。
与STUN建立连接,获得自己的外网IP地址和端口,以及是否能够进行内网穿越。不支持内网穿越的情况下还需要连接TURN服务器进行中继通信。
WebRTC客户端拿到自己的外网IP地址和端口后,通过信令服务器将自己的信息(candidate信息)交换给对方。当双方都获取到对方的地址后,它们就可以尝试NAT穿越,进行P2P连接了。
WebRTC中的基本 媒体单位,一个MediaStreamTrack包含一种媒体源(媒体设备或录制内容)返回的单一类型的媒体(如音频,视频) 。单个轨道可包含多个通道,如立体声源尽管由多个音频轨道构成,但也可以看作是一个轨道 。
MediaStream是MediaStreamTrack的合集,可以包含 >=0 个 MediaStreamTrack 。MediaStream能够确保它所包含的所有轨道都是是同时播放 的,以及轨道的单一性。
source 与 sink 在MediaTrack的源码中,MediaTrack都是由对应的source和sink组成的。
浏览器中存在从source到sink的媒体管道。其中source负责生产媒体资源 ,包括多媒体文件,web资源等静态资源以及麦克风采集的音频,摄像头采集的视频等动态资源。而sink则负责消费source生产媒体资源,也就是通过等媒体标签进行展示,或者是通过RTCPeerConnection将source通过网络传递到远端 。RTCPeerConnection可同时扮演source与sink的角色,作为sink,可以将获取的source降低码率,缩放,调整帧率等,然后传递到远端,作为source,将获取的远端码流传递到本地渲染。
MediaTrackConstraints
描述MediaTrack的功能以及每个功能可以采用的一个或多个值,从而达到选择和控制源 的目的。 MediaTrackConstraints
可作为参数传递给applyConstraints()
以达到控制轨道属性的目的,同时可以通过调getConstraints()
用来查看最近应用自定义约束。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 const constraints = { width: {min: 640, ideal: 1280}, height: {min: 480, ideal: 720}, advanced: [ {width: 1920, height: 1280}, {aspectRatio: 1.333} ] }; //使用1280x720的摄像头分辨率 const constraints = { audio: true, video: { width: 1280, height: 720 } }; const constraints = { audio: true, video: { facingMode: "user" } }; const constraints = { audio: true, video: { facingMode: { exact: "environment" } } }; //{ video: true }也是一个MediaTrackConstraints对象,用于指定请求的媒体类型和相对应的参数。 navigator.mediaDevices.getUserMedia({ video: true }) .then(mediaStream => { const track = mediaStream.getVideoTracks()[0]; track.applyConstraints(constraints) .then(() => { // Do something with the track such as using the Image Capture API. }) .catch(e => { // The constraints could not be satisfied by the available devices. }); });
可将MediaStream对象直接赋值给HTMLMediaElement
接口的 srcObject
属性。
1 video.srcObject = stream;
WebRTC 实现点对点通信
检测本地音视频设备和进行采集音视频的采集;
通过信令服务器与对方建立连接;
创建RTCPeerConnection对象
绑定音视频数据
进行媒体协商
交换candidate信息
音视频数据传输与渲染
检测本地音视频设备 通过MediaDevices.enumerateDevices()
我们可以得到一个本机可用的媒体输入和输出设备的列表,例如麦克风,摄像机,耳机设备等。
1 2 3 4 //获取媒体设备 navigator.mediaDevices.enumerateDevices().then(res => { console.log(res); });
列表中的每个媒体输入都可作为MediaTrackConstraints 中对应类型的值,如一个音频设备输入audioDeviceInput可设置为MediaTrackConstraints中audio属性的值。
1 const constraints = { audio : audioDeviceInput }
将该constraint值作为参数传入到MediaDevices.getUserMedia(constraints)
中,便可获得该设备的MediaStream。
采集本地音视频数据 可通过调用MediaDevices.getUserMedia()
来访问本地媒体,调用该方法后浏览器会提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream
,里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。
1 2 3 4 5 6 7 8 navigator.mediaDevices.getUserMedia(constraints) .then(function(stream) { /* 使用这个stream*/ video.srcObject = stream; }) .catch(function(err) { /* 处理error */ });
通过信令服务器与对方建立连接 信令服务器主要用于帮我们进行业务逻辑的处理(如加入房间、离开房间)以及进行媒体协商和交换candidate。
信令服务器可以有很多种方案,在这里我们借助node.js和socket.io实现一个简单的信令服务器。
创建HTTP服务器 1 2 3 4 5 6 7 let http = require('http'); // 提供HTTP 服务 let express = require('express'); let app = express(); let http_server = http.createServer(app); http_server.listen(8081, '127.0.0.1');
引入 socket.io 实现两端的实时通信 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let http = require('http'); // 提供HTTP 服务 const { Server } = require('socket.io'); let express = require('express'); let app = express(); //HTTP 服务 let http_server = http.createServer(app); http_server.listen(8081, '127.0.0.1'); const io = new Server(http_server, { // 处理跨域配置 cors: { origin: ['http://127.0.0.1:3000', 'http://localhost:3000'], credentials: true, }, });
监听客户端的消息 1 socket.on('messageName', messageHandler)
客户端加入房间
向房间内的客户端发送消息 1 socket.to(roomId).emit('messageName', data);
转发消息 1 2 3 4 // 用于转发sdp、candidate等消息 socket.on('message', ({ roomId, data }) => { socket.to(roomId).emit('message', data); });
创建RTCPeerConnection对象 RTCPeerConnection 接口代表一个由本地计算机到远端的WebRTC连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。
1 const pc = new RTCPeerConnection()
绑定音视频数据 我们可以通过 addTrack 方法和 addStream 方法(已过时,不推荐)将音视频数据和 RTCPeerConnection 对象进行绑定。
1 2 3 mediaStream.getTracks().forEach(track => { peerConnection.addTrack(track, mediaStream); });
进行媒体协商 所谓的媒体协商,就是交换双方SDP信息,SDP包含音视频的编解码(codec ),源地址,和时间信息等信息。
呼叫端获取本地sdp(offer),调用pc.setLocalDescription(offer)保存本地的sdp信息后,通过信令服务器发送本地sdp到远端。
1 2 3 4 5 6 7 8 9 10 11 12 13 // 呼叫端获取本地sdp(offer) pc.createOffer().then(offer => { console.log('------ 获取到了本地offer', offer); // 绑定本地sdp信息 pc.setLocalDescription(offer); // 通过信令服务器发送本地sdp到远端 signalServer.send({ type: 'offer', value: offer, }); });
被叫端接收到来自远端的offer后,调用 pc.setRemoteDescription(offer) 绑定远端sdp,然后调用pc.createAnswer() 创建本地sdp并使用 pc.setLocalDescription(answer) 进行保存,最后利用信令服务器将 answer sdp 发送给远端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const onGetRemoteOffer = offer => { console.log('------ 获取到了远端offer', offer); // 远端发起呼叫,开始建立连接 // 绑定远端sdp pc.setRemoteDescription(offer); // 创建本地sdp pc.createAnswer().then(answer => { // 绑定本地sdp pc.setLocalDescription(answer); console.log('------ 获取到了本地answer', answer); // 发送本地sdp到远端 signalServer.send({ type: 'answer', value: answer, }); }); };
呼叫端接收到远端的answer后,调用 pc.setRemoteDescription(answer) 绑定远端sdp。
1 2 3 4 5 const onGetRemoteAnswer = answer => { console.log('------ 获取到了远端answer', answer); // 绑定远端sdp pc.setRemoteDescription(answer); };
ICE 当媒体协商完成后,WebRTC就开始建立网络连接了。建立网络连接的前提是客户端需要知道对端的外网地址,这个获取并交换外网地址的过程,我们称为ICE 。
收集 WebRTC内部集成了收集Candidate的功能。收集到Candidate后,为了通知上层,WebRTC还提供onicecandidate事件。
1 2 3 4 5 6 7 8 // 监听 candidate 获取事件 pc.addEventListener('icecandidate', event => { const candidate = event.candidate; if (candidate) { console.log('------ 获取到了本地 candidate:', candidate) //... } });
交换 收集到candidate后,可以通过信令系统将candidate信息发送给远端。
1 2 // 发送candidate到远端 signalServer.send({ type: 'candidate', value: candidate });
远端接收到对端的candidate后,会与本地的candidate形成CandidatePair(即连接候选者对)。有了CandidatePair,WebRTC就可以开始尝试建立连接了。
1 2 3 4 5 // 获取到远端的candidate const onGetRemoteCandidate = candidate => { console.log('------ 获取到了远端candidate', candidate); pc.addIceCandidate(candidate); };
远端音视频数据接收与渲染 当双方都获取到对端的candidate信息后,WebRTC内部就开始尝试建立连接了。连接一旦建成,音视频数据就开始源源不断地由发送端发送给接收端。
通过RTCPeerConnection对象的track事件,我们能接收到远端的音视频数据并进行渲染。
1 2 3 4 5 6 7 // 监听到远端传过来的媒体数据 pc.addEventListener('track', e => { console.log('------ 获取到了远端媒体数据:', e); if (remoteVideo.srcObject !== e.streams[0]) { remoteVideo.srcObject = e.streams[0]; } });
代码实现 & 讲解 环境 vscode + nodejs + xirsys
1 2 3 npm init //创建初始项目 npm install express // 安装express npm install socket.io
配置 https ,如果不配的话局域网内开摄像头会被拦截。
1 2 openssl genrsa -out privkey.key 2048 openssl req -new -x509 -key privkey.key -out cacert.pem -days 1095
具体代码实现 具体就是两个文件: index.js 与 camera.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 ## index.js const { Socket } = require ('socket.io' );var express = require ('express' );const { fstat } = require ('fs' );var app = express ();var http = require ('http' ).createServer (app);var fs = require ('fs' )let sslOptions = { key : fs.readFileSync ('./private.key' ), cert : fs.readFileSync ('./cacert.pem' ) }; const https = require ('https' ).createServer (sslOptions, app);var io = require ('socket.io' )(https);https.listen (443 , () => { console .log ('https listen on' ); }); app.use ("/static" , express.static ('static/' )); app.get ('/camera' , (req, res ) => { res.sendFile (__dirname + '/camera.html' ); }); io.on ("connection" , (socket ) => { socket.join (socket.id ); console .log ("a user connected " + socket.id ); socket.on ("disconnect" , () => { console .log ("user disconnected " + socket.id ); socket.broadcast .emit ('user disconnected' , socket.id ); }) socket.on ('new user greet' , (data ) => { console .log (data) console .log (socket.id + " greet " + data.msg ); socket.broadcast .emit ("need connect" , {sender : socket.id , msg : data}); }); socket.on ('ok we connect' , (data ) => { io.to (data.receiver ).emit ('ok we connect' , {sender : data.sender }); }); socket.on ('sdp' , (data ) => { console .log ('sdp' ); console .log (data.description ); socket.to (data.to ).emit ('sdp' , { description : data.description , sender : data.sender }); }); socket.on ('ice candidates' , (data ) => { console .log ('ice candidates: ' ); console .log (data); socket.to (data.to ).emit ('ice candidates' , { candidate : data.candidate , sender : data.sender }); }); }); http.listen (3000 , () => { console .log ('listening on *:3000' ); });
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 ## camera.html <!DOCTYPE html > <html > <head > <title > Camera</title > </head > <body > <h1 id ="user-id" > 用户名称</h1 > <ul id ="user-list" > </ul > <video id ="video-local" controls autoplay > </video > <div id ="videos" > </div > </body > <script src ="//cdn.bootcdn.net/ajax/libs/jquery/3.4.1/jquery.js" > </script > <script src ="//cdn.bootcdn.net/ajax/libs/socket.io/3.0.4/socket.io.js" > </script > <script > function getUserMedia (constrains, success, error ) { let promise; if (navigator.mediaDevices .getUserMedia ) { promise = navigator.mediaDevices .getUserMedia (constrains).then (success).catch (error); } else if (navigator.webkitGetUserMedia ) { promise = navigator.webkitGetUserMedia (constrains).then (success).catch (error); } else if (navigator.mozGetUserMedia ) { promise = navagator.mozGetUserMedia (constrains).then (success).catch (error); } else if (navigator.getUserMedia ) { promise = navigator.getUserMedia (constrains).then (success).catch (error); } return promise; } function canGetUserMediaUse ( ) { return !!(navigator.mediaDevices .getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia ); } const localVideoElm = document .getElementById ("video-local" ); $('document' ).ready (() => { InitCamera (); }); const iceServer = { xxx } var pc = []; var localStream = null ; function InitCamera ( ){ if (canGetUserMediaUse ()){ getUserMedia ({ video : true , audio : false }, (stream ) => { localStream = stream; localVideoElm.srcObject = stream; $(localVideoElm).width (800 ); }, (err ) => { console .log ('访问用户媒体失败' , err.name , err.message ); }); } else { alert ("您的浏览器不兼容" ); } } function StartCall (parterName, createOffer ){ pc[parterName] = new RTCPeerConnection (iceServer); if (localStream){ localStream.getTracks ().forEach ((track ) => { pc[parterName].addTrack (track, localStream); }); } else { if (canGetUserMediaUse ()){ getUserMedia ({ video : true , audio : false }, function (stream ){ localStream = stream; localVideoElm.srcObject = stream; $(localVideoElm).width (800 ); }, function (error ){ console .log ("访问用户媒体设备失败" , error.name , error,message); }) }else { alert ("您的浏览器不兼容" ); } } if (createOffer){ pc[parterName].onnegotiationneeded = () => { pc[parterName].createOffer ().then ((offer ) => { return pc[parterName].setLocalDescription (offer); }).then (() => { socket.emit ('sdp' , { type : 'video-offer' , description : pc[parterName].localDescription , to : parterName, sender : socket.id }); }) }; } pc[parterName].ontrack = (ev ) => { let str = ev.streams [0 ]; if (document .getElementById (`${parterName} -video` )){ document .getElementById (`${parterName} -video` ).srcObject = str; } else { let newVideo = document .createElement ('video' ); newVideo.id = `${parterName} -video` ; newVideo.autoplay = true ; newVideo.controls = true ; newVideo.className = 'remote-video' ; newVideo.srcObject = str; document .getElementById ('videos' ).appendChild (newVideo); } } pc[parterName].onicecandidate = ({candidate} ) => { socket.emit ('ice candidates' ,{ candidate : candidate, to : parterName, sender : socket.id }); } } var socket = io (); socket.on ('connect' , () => { console .log ('connect ' + socket.id ); $('#user-id' ).text (socket.id ); pc.push (socket.id ); socket.emit ('new user greet' , { sender : socket.id , msg : 'hello' }); socket.on ('need connect' , (data ) => { console .log (data); let li = $('<li>' ).text (data.sender ).attr ('user-id' , data.sender ); $('#user-list' ).append (li); let button = $('<button class="call">通话</button>' ); button.appendTo (li); $(button).click (function ( ){ console .log ($(this ).parent ().attr ('user-id' )); StartCall ($(this ).parent ().attr ('user-id' ), true ); }); socket.emit ('ok we connect' , { receiver : data.sender , sender : socket.id }); }); socket.on ('ok we connect' , (data ) => { console .log (data); $('#user-list' ).append ($('<li>' ).text (data.sender ).attr ('user-id' , data.sender )); }); socket.on ('user disconnected' , (socket_id ) => { console .log ("disconnect : " + socket_id); $('#user-list li[user-id="' + socket_id + '"]' ).remove (); }); socket.on ('sdp' , (data ) => { if (data.description .type === 'offer' ){ StartCall (data.sender , false ); let desc = new RTCSessionDescription (data.description ); pc[data.sender ].setRemoteDescription (desc).then (() => { pc[data.sender ].createAnswer ().then ((answer ) => { return pc[data.sender ].setLocalDescription (answer); }).then (() => { socket.emit ('sdp' , { type : 'video-answer' , description : pc[data.sender ].localDescription , to : data.sender , sender : socket.id }); }).catch (); }) } else if (data.description .type === 'answer' ){ let desc = new RTCSessionDescription (data.description ); pc[data.sender ].setRemoteDescription (desc); } }) socket.on ('ice candidates' , (data ) => { console .log ('ice candidate: ' + data.candidate ); if (data.candidate ){ var candidate = new RTCIceCandidate (data.candidate ); pc[data.sender ].addIceCandidate (candidate).catch (); } }); }); </script > </html >
运行效果 输入”node index.js”并开始运行。
保证视频通信的双方连一个局域网,否则访问不到页面。
两台设备访问 https://192.168.43.185/camera,如下图所示:
其中 iGxxxAAAD为此设备的ID,VssxxxAAAF为远程设备的ID。
点击通话,效果如下图所示:
项目代码 https://github.com/WD-2711/webrtc-demo