这篇文章开端会完结一个1对1WebRTC和多对多的WebRTC,以及根据屏幕同享的录制。本篇会完结信令和前端部分,信令运用fastity来树立,前端部分运用Vue3来完结。

为什么要运用WebRTC

WebRTC全称WebReal-Time Communication,是一种实时音视频的技能,它的优势是低延时。

本片文章食用者要求

  • 了解音视频基础
  • 能树立简略的node服务,docker装备
  • vue结构的运用

环境树立及要求

废话不多说,现在开端树立环境,首要是需求敞开socket服务,采用的是fastify来进行树立。概况能够见文档地址,本例运用的是3.x来发动的。接下来装置fastify-socket.io3.0.0插件,具体装备能够见文档,此处不做具体解说。接下来是树立Vue3,运用 vite 脚手架树立简略的demo。

要求:前端服务运行在localhost或者https下。node需求redis进行数据缓存

获取音视频

要完结实时音视频第一步当然是要能获取到视频流,在这儿咱们运用浏览器供给的API,MediaDevices来进行摄像头流的捕获

enumerateDevices

第一个要介绍的API是enumerateDevices,是恳求一个可用的媒体输入和输出设备的列表,例如麦克风,摄像机,耳机设备等。直接在控制台执行API,获取的设备如图

从0树立一个WebRTC,完结多房间多对多通话,并完结屏幕录制

咱们注意到里边回来的设备ID和label是空的,这是因为浏览器的安全策略限制,有必要授权摄像头或麦克风才能允许回来设备ID和设备标签,接下来咱们介绍如何恳求摄像头和麦克风

getUserMedia

这个API望文生义,便是去获取用户的Meida的,那咱们直接执行这个API来看看作用

ps: 因为的代码片段的iframe没有装备allow="display-capture *;microphone *; camera *"特点,需求手动翻开概况检查作用

经过上述比如咱们能够获取到本机的音视频画面,而且能够播放在video标签里,那么咱们能够在获取了用户的流之后,从头再获取一次设备列表看看发生了什么变化

从0树立一个WebRTC,完结多房间多对多通话,并完结屏幕录制

在获取了音视频之后,获取的设备列表的具体信息现已呈现,咱们就能够获取指定设备的音视频数据,概况能够见

这儿介绍一下getUserMedia的参数constraints,

视频参数装备

interface MediaTrackConstraintSet {
    // 画面比例
    aspectRatio?: ConstrainDouble;
    // 设备ID,能够从enumerateDevices中获取
    deviceId?: ConstrainDOMString;
    // 摄像头前后置形式,一般适用于手机
    facingMode?: ConstrainDOMString;
    // 帧率,收集视频的方针帧率
    frameRate?: ConstrainDouble;
    // 组ID,用一个设备的输入输出的组ID是同一个
    groupId?: ConstrainDOMString;
    // 视频高度
    height?: ConstrainULong
    // 视频宽度
    width?: ConstrainULong;
}

音频参数装备

interface MediaTrackConstraintSet {
    // 是否敞开AGC自动增益,能够在原有音量上添加额外的音量
    autoGainControl?: ConstrainBoolean;
    // 声道装备
    channelCount?: ConstrainULong;
    // 设备ID,能够从enumerateDevices中获取
    deviceId?: ConstrainDOMString;
    // 是否敞开回声消除
    echoCancellation?: ConstrainBoolean;
    // 组ID,用一个设备的输入输出的组ID是同一个
    groupId?: ConstrainDOMString;
    // 延迟大小
    latency?: ConstrainDouble;
    // 是否敞开降噪
    noiseSuppression?: ConstrainBoolean;
    // 采样率单位Hz
    sampleRate?: ConstrainULong;
    // 采样大小,单位位
    sampleSize?: ConstrainULong;
    // 本地音频在本地扬声器播放
    suppressLocalAudioPlayback?: ConstrainBoolean;
}

1对1衔接

当咱们收集到了音视频数据,接下来便是要树立链接,在开端之前需求科普一下WebRTC的工作方式,咱们常见有三种WebRTC的网络结构

  1. Mesh
  2. MCU
  3. SFU 关于这三种形式的差异能够检查 文章来了解

在这儿因为设备的限制,咱们采用Mesh的方案来进行开发

1对1的流程

咱们树立1对1的链接需求知道后流程是怎样流通的,接下来上一张图,便能够清晰的了解

从0树立一个WebRTC,完结多房间多对多通话,并完结屏幕录制

这儿是由ClientA建议B来承受A的视频数据。上图总结能够为A创立本地视频流,把视频流添加到PeerConnection里边 创立一个Offer给B,B收到Offer以后,保存这个offer,并呼应这个Offer给A,A收到B的呼应后保存A的远端呼应,进行NAT穿透,完结链接树立。

话现已讲了这么多,咱们该怎样树立呢,光说不做假把式,接下来,用咱们的项目创立一个来试试

初始化

首要发动fastify服务,接下来在Vue项目装置socket.io-client@4然后衔接服务端的socket

import { v4 as uuid } from 'uuid';
import { io, Socket } from 'socket.io-client';
const myUserId = ref(uuid());
let socket: Socket;
socket = io('http://127.0.0.1:7070', {
  query: {
    // 房间号,由输入框输入取得
    room: room.value,
    // userId经过uuid获取
    userId: myUserId.value,
    // 昵称,由输入框输入取得
    nick: nick.value
  }
});

能够检查chrome的控制台,检查ws的链接情况,假如呈现跨域,请检查socket.io的server装备并敞开cors装备。

创立offer

开端创立RTCPeerConnection,这儿采用google的公共stun服务

const peerConnect = new RTCPeerConnection({
  iceServers: [
    {
      urls: "stun:stun.l.google.com:19302"
    }
  ]
})

根据上面的流程图咱们下一步要做的工作是用上面的方式获取视频流,并将获取到的流添加到RTCPeerConnection中,并创立offer,把这个offer设置到这个rtcPeer中,并把offer发送给socket服务

let localStream: MediaStream;
stream.getTracks().forEach((track) => {
  peerConnect.addTrack(track, stream)
})
const offer = await peerConnect.createOffer();
await peerConnect.setLocalDescription(offer);
socket.emit('offer', { creatorUserId: myUserId.value, sdp: offer }, (res: any) => {
  console.log(res);
});

socket 服务收到了这份offer后需求给B发送A的offer

fastify.io.on('connection', async (socket) => {
    socket.on('offer', async (offer, callback) => {
      socket.emit('offer', offer);
      callback({
        status: "ok"
      })
    })
})

处理offer

B需求监听socket里边的offer事情并创立RTCPeerConnection,将这个offer设置到远端,接下来来创立呼应。而且将这个呼应设置到本地,发送answer事情回复给A

socket.on('offer', async (offer: { sdp: RTCSessionDescriptionInit, creatorUserId: string }) => {
    const peerConnect = new RTCPeerConnection({
      iceServers: [
        {
          urls: "stun:stun.l.google.com:19302"
        }
      ]
    })
    await peerConnect.setRemoteDescription(offer.sdp);
    const answer = await peerConnect.createAnswer();
    await peerConnect.setLocalDescription(answer);
    socket.emit('answer', { sdp: answer }, (res: any) => {
      console.log(res);
    }) 
})

处理answer

服务端播送answer

socket.on('offer', async (offer, callback) => {
      socket.emit('offer', offer);
      callback({
        status: "ok"
      })
    })

A监听到socket里边的answer事情,需求将刚才的自己的RTCpeer添加远端描绘

socket.on('answer', async (data: { sdp: RTCSessionDescriptionInit }) => {
    await peerConnect.setRemoteDescription(data.sdp)
})

处理ICE-candidate

接下来A会获取到ICE候选信息,需求发送给B

peerConnect.onicecandidate = (candidateInfo: RTCPeerConnectionIceEvent) => {
  if (candidateInfo.candidate) {
    socket.emit('ICE-candidate', { sdp: candidateInfo.candidate }, (res: any) => {
      console.log(res);
    })
  }
}

播送消息是同理这儿就不再赘述了,B获取到了A的ICE,需求设置候选

socket.on('ICE-candidate', async (data: { sdp: RTCIceCandidate }) => {
   await peerConnect.addIceCandidate(data.sdp)
})

接下来B也会获取到ICE候选信息,同理需求发送给A,待A设置完结之后便能够树立链接,代码同上,B接下来会收到流添加的事情,这个事情会有两次,分别是音频和视频的数据

处理音视频数据

peerConnect.ontrack = (track: RTCTrackEvent) => {
    if (track.track.kind === 'video') {
      const video = document.createElement('video');
      video.srcObject = track.streams[0];
      video.autoplay = true;
      video.style.setProperty('width', '400px');
      video.style.setProperty('aspect-ratio', '16 / 9');
      video.setAttribute('id', track.track.id)
      document.body.appendChild(video)
    }
    if (track.track.kind === 'audio') {
      const audio = document.createElement('audio');
      audio.srcObject = track.streams[0];
      audio.autoplay = true;
      audio.setAttribute('id', track.track.id)
      document.body.appendChild(audio)
    }
}

到这儿你就能够见到两个视频树立的P2P链接了。到这儿停止仅仅树立了视频的1对1链接,但是咱们能够经过这些操作进行复制,就能进行多对多的衔接了。

多对多衔接

在开端咱们需求知道,一个人和另一个人树立衔接两边都需求创立自己的peerConnection。关于多人的情况,首要咱们需求知道进入的房间里边当时的人数,给每个人都创立一个RtcPeer,一起收到的人也回复这个offer给建议的人。关于后进入的人,需求让现已创立音视频的人给后进入的人创立新的offer。

根据上面的流程,咱们现在先完结一个成员列表的接口

成员列表的接口

在咱们登录socket服务的时分咱们在query参数里边有房间号,userId和昵称,咱们能够经过redis记载对应的房间号的登录和登出,从而完结成员列表。

能够在某一个人登录的时分获取一下redis对应房间的成员列表,假如没有这个房间,就把这个人丢进新的房间,而且存储到redis中,方便其他人登录这个房间的时分知道现在有多少人。

fastify.io.on('connection', async (socket) => {
  const room = socket.handshake.query.room;
  const redis = fastify.redis;
  let userList;
  // 获取当时房间的数据
  await getUserList()
    async function getUserList() {
      const roomUser = await redis.get(room);
      if (roomUser) {
        userList = new Map(JSON.parse(roomUser))
      } else {
        userList = new Map();
      }
    }
    async function setRedisRoom() {
      await redis.set(room, JSON.stringify([...userList]))
    }
    function rmUser(userId) {
      userList.delete(userId);
    }
    if (room) {
      // 将这人加入到对应的socket房间
      socket.join(room);
      await setRedisRoom();
      // 播送有人加入了
      socket.to(room).emit('join', userId);
    }
    // 这个人断开了链接需求将这个人从redis中删去
    socket.on('disconnect', async (socket) => {
      await getUserList();
      rmUser(userId);
      await setRedisRoom();
    })
})

到上面停止,咱们完结了成员的记载、播送和删去。接下来是需求完结一个成员列表的接口,供给给前端项目调用。

fastify.get('/userlist', async function (request, reply) {
  const redis = fastify.redis;
  return await redis.get(request.query.room);
})

多对多初始化

因为需求给每个人发送offer,需求对上面的初始化函数进行封装。

/**
 * 创立RTCPeerConnection
 * @param creatorUserId 创立者id,自己
 * @param recUserId 接收者id
 */
const initPeer = async (creatorUserId: string, recUserId: string) => {
  const peerConnect = new RTCPeerConnection({
    iceServers: [
      {
        urls: "stun:stun.l.google.com:19302"
      }
    ]
  })
  return peerConnect;
})

因为存在多份rtc的映射联系,咱们这儿能够用Map来完结映射的保存

const peerConnectList = new Map();
const initPeer = () => {
   // ice,track,new Peer等其他代码
   ......
   peerConnectList.set(`${creatorUserId}_${recUserId}`, peerConnect);
}

获取成员列表

上面完结了成员列表。接下来进入了对应的房间后需求轮询获取对应的成员列表

let userList = ref([]);
const intoRoom = () => {
    //其他代码
    ......
    setInterval(()=>{
      axios.get('/userlist', { params: { room: room.value }}).then((res)=>{
        userList.value = res.data
      })
    }, 1000)
}

创立多对多的Offer和Answer

在咱们获取到视频流的时分,能够对在线列表里除了自己的人都创立一个RTCpeer,来进行1对1衔接,从而到达多对多衔接的作用。

// 过滤自己
const emitList = userList.value.filter((item) => item[0] !== myUserId.value);
for (const item of emitList) {
  // item[0]便是方针人的userId
  const peer = await initPeer(myUserId.value, item[0]);
  await createOffer(item[0], peer);
}
const createOffer = async (recUserId: string, peerConnect: RTCPeerConnection, stream: MediaStream = localStream) => {
  if (!localStream) return;
  stream.getTracks().forEach((track) => {
    peerConnect.addTrack(track, stream)
  })
  const offer = await peerConnect.createOffer();
  await peerConnect.setLocalDescription(offer);
  socket.emit('offer', { creatorUserId: myUserId.value, sdp: offer, recUserId }, (res: any) => {
    console.log(res);
  });
}

那么在socket服务中咱们怎样只给对应的人进行事情播送,不对其他人进行播送,咱们能够用找到这个人userId对应的socketId,从而只给这一个人播送事情。

// 首要获取IO对应的nameSpace
const IONameSpace = fastify.io.of('/');
// 发送Offer给对应的人
socket.on('offer', async (offer, callback) => {
  // 从头从reids获取用户列表
  await getUserList();
  // 找到方针的UserId的数据
  const user = userList.get(offer.recUserId);
  if (user) {
    // 找到对应的socketId
    const io = IONameSpace.sockets.get(user.sockId);
    if (!io) return;
    io.emit('offer', offer);
    callback({
      status: "ok"
    })
  }
})

其他人需求监听socket的事情,每个人都需求处理对应自己的offer。

socket.on('offer', handleOffer);
const handleOffer = async (offer: { sdp: RTCSessionDescriptionInit, creatorUserId: string, recUserId: string }) => {
  const peer = await initPeer(offer.creatorUserId, offer.recUserId);
  await peer.setRemoteDescription(offer.sdp);
  const answer = await peer.createAnswer();
  await peer.setLocalDescription(answer);
  socket.emit('answer', { recUserId: myUserId.value, sdp: answer, creatorUserId: offer.creatorUserId }, (res: any) => {
    console.log(res);
  })
}

接下来的步骤其实便是和1对1是一样的了,后面还需求建议offer的人处理对应peer的offer、以及ICE候选,还有流进行挂载播放。

socket.on('answer', handleAnswer)
// 应对方回复
const handleAnswer = async (data: { sdp: RTCSessionDescriptionInit, recUserId: string, creatorUserId: string }) => {
  const peer = peerConnectList.get(`${data.creatorUserId}_${data.recUserId}`);
  if (!peer) {
    console.warn('handleAnswer peer 获取失利')
    return;
  }
  await peer.setRemoteDescription(data.sdp)
}
......处理播放,处理ICE候选

到目前停止,就完结了一个根据mesh的WebRTC的多对多通讯。在这儿附上了一个完好的Demo可供参考 socketServer FontPage

根据WebRTC的屏幕录制

getDisplayMedia

这个API是在MediaDevices里边的一个方法,是用来获取屏幕同享的。

这个MediaDevices接口的getDisplayMedia()方法提示用户去选择和授权捕获展现的内容或部分内容(如一个窗口)在一个MediaStream里.然后,这个媒体流能够经过运用MediaStream Recording API被记载或者作为WebRTC会话的一部分被传输。

await navigator.mediaDevices.getDisplayMedia()

MediaRecorder

获取到屏幕同享流后,需求运用 MediaRecorder这个api来对流进行录制,接下来咱们先获取屏幕流,一起创立一个MeidaRecord类

let screenStream: MediaStream;
let mediaRecord: MediaRecorder;
let blobMedia: (Blob)[] = [];
const startLocalRecord = async  () => {
  blobMedia = [];
  try {
      screenStream = await navigator.mediaDevices.getDisplayMedia();
      screenStream.getVideoTracks()[0].addEventListener('ended', () => {
        console.log('用户中断了屏幕同享');
        endLocalRecord()
      })
      mediaRecord = new MediaRecorder(screenStream, { mimeType: 'video/webm' });
      mediaRecord.ondataavailable = (e) => {
        if (e.data && e.data.size > 0) {
          blobMedia.push(e.data);
        }
      };
      // 500是每隔500ms进行一个保存数据
      mediaRecord.start(500)
  } catch(e) {
      console.log(`屏幕同享失利->${e}`);
  }
}

获取到了之后能够运用 Blob 进行处理

const replayLocalRecord = async () => {
  if (blobMedia.length) {
    const scVideo = document.querySelector('#screenVideo') as HTMLVideoElement;
    const blob = new Blob(blobMedia, { type:'video/webm' })
    if(scVideo) {
       scVideo.src = URL.createObjectURL(blob);
    }
  } else {
    console.log('没有录制文件');
  }
}
const downloadLocalRecord = async () => {
  if (!blobMedia.length) {
    console.log('没有录制文件');
    return;
  }
  const blob = new Blob(blobMedia, { type: 'video/webm' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `录屏_${Date.now()}.webm`;
  a.click();
}

这儿有一个根据Vue2的完好比如

ps: 因为的代码片段的iframe没有装备allow="display-capture *;microphone *; camera *"特点,需求手动翻开概况检查作用

后续将会更新,WebRTC的自动化测验,视频画中画,视频截图等功能