因为疫情的原因,线上视频会议软件异军突起,成为了在家办公的主要沟通渠道。而最近抖音上“蚂蚁呀嘿”恶搞换脸的小视频也突然火了起来,那我就想了想能不能在视频会议的时候换张脸活跃下气氛?在 Github 上一番搜寻之后发现还真有办法,有一个开源的 Python 人工智能换脸的库,那正好趁着这个机会研究一下前端 WebRTC 实现视频通话功能,外加换脸操作。先看一下效果吧:
因为有涉及到一点点的后台,所以项目分成了两部分,一个是用于存放前端代码的 frontend 项目和存放后端代码的 backend 项目:
项目根目录
|-- backend
|-- frontend
另外这个视频需要电脑上有摄像头,没有的话可以想办法把手机当作电脑的摄像头。
注意:本教程中的代码仅供展示 AI 换脸技术的应用,不可以用于获取其他人隐私或其他任何非法目的。
编写页面
因为是前端实现,首先肯定是编写页面。这个页面比较简单,就是一个视频组件、显示用户 ID 的文本、呼叫对方视频的输入框和按钮,视频组件默认显示自己的视频,当视频接通之后就会显示对方的视频,而自己的视频会缩小到右上角。
HTML
在 frontend 目录下新建 index.html、style.css 和 index.js 文件。首先看一下 HTML 结构,index.html 中主要的代码如下:
<!-- frontend/index.html -->
<head>
<!-- 其他代码 -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<div id="container">
<div class="videos">
<video id="myVideo" class="videoSize" autoplay></video>
<video id="peerVideo" class="videoSize" autoplay></video>
</div>
<p id="idText"></p>
<div class="call">
<input type="text" id="peerIdInput" placeholder="请输入对方 id" />
<button id="joinBtn">视频通话</button>
</div>
</div>
</main>
<script src="index.js"></script>
</body>
每个标签的作用是:
<main />
用于设置页面背景,把所有组件居中对齐。<div class="videos">
里边分别放了显示自己视频#myVideo
和对方视频#peerVideo
的<video />
组件,并设置为自动播放,以便于在加载摄像头后立刻开始播放画面。<p id="idText">
在页面打开时显示自己的用户 ID,相当于是电话号码。<div class="call">
里是输入对方 ID 的文本框#peerIdInput
和呼叫按钮#joinBtn
。- 最后在
<head />
中引入样式文件 style.css,在<body />
结束前引入 index.js。我们将主要在 index.js 中编写代码。
CSS
css 的代码都比较简单,基本就是设置一下样式,这里介绍一下重要的部 分,剩余的可以在源代码中查看。因为自己的视频要在视频接通时移动到右上角,那么就需要把 <div class="videos">
容器设置为相对定位,把我的视频和对方的视频设置成一样的宽高,然后先隐藏对方视频,当视频接通时,利用 JavaScript 加上接通后,我的视频的样式,把我的视频设置为绝对定位,宽高调小,放到右上角:
/* frontend/style.css */
videos {
position: relative;
}
.videoSize {
width: 500px;
height: 600px;
object-fit: cover; /* 让视频按比例占满整个空间 */
}
.rightTop {
/* 以下是通话中的样式 */
position: absolute;
width: 150px;
height: 180px;
right: 0;
top: 0;
}
#peerVideo {
display: none;
}
其他 组件基本只是设置了下 Grid 布局、宽高、大小、阴影、背景、字体,没有什么特殊的,可以直接查看源代码。样式中所用到的图标在 frontend/icons
目录下。
访问摄像头
接下来我们先熟悉一下访问摄像头的代码。在 JavaScript 中访问用户的摄像头主要使用 navigator.mediaDevices.getUserMedia()
方法, 它接收一个对象作为参数,用于指定要获取的设备,例如视频或音频,然后返回一个 Promise,在 Promise 完成之后它会传递给我们一个 Stream 流,我们把它放到 <video />
标签的 srcObject
属性中就可以了,是不是很简单?代码如下:
// frontend/index.js
const myVideo = document.getElementById('myVideo')
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => {
myVideo.srcObject = stream
})
在这段代码中:
- 获取了
#myVideo
这个<video />
组件。 - 使用
navigator.mediaDevices.getUserMedia()
并给它传递了一个对象,对象的 video 和 audio 属性都设置为了 true,表示要访问摄像头和音频设备。
这时,使用 VS Code 的 Live Server 插件运行项目(没有的话安装一下,很简单),在 index.html 文件里右击,选择 Open with Live Server,打开之后,浏览器可能会提示此网站需要访问摄像头和音频设备,点击允许,就能看到自己的视频了。
编写后 台
要实现视频通话,需要使用 WebRTC 技术,这个技术牵扯的概念和 API 过于庞大和复杂,不过有开源的库来帮我们简化了 WebRTC 的操作,这里使用一个叫做 Peer.js 的库,它封装了 WebRTC 杂乱的 API,提供了完整的、可配置的、易于使用的 API。 https://peerjs.com/ 我们将会通过把 Peer 附加到 Express.js 服务器上,来生成用户 ID 并管理 WebRTC 连接。首先在 backend 目录下运行:
npm init -y
# 或
yarn init -y
接着初始化一个 node.js 项目,使用 npm 或 yarn 安装 peer 和 express 依赖,因为想在改动代码 时自动重启服务,我们也可以再安装一个 nodemon 依赖:
yarn add peer express nodemon
# 或
npm install --save peer express nodemon
安装完成之后新建一个 server.js 文件,整个后台服务我们就只需要这一个文件,都是一些简单的初始化代码,它里边的内容是:
const express = require('express')
const { ExpressPeerServer } = require('peer')
const app = express()
const server = app.listen(3000)
const peerServer = ExpressPeerServer(server, {
debug: true,
path: '/'
})
app.use('/video', peerServer)
这些代码的含义是:
- 导入 express 库,并从 peer 库中导入与 express 进行结合的 ExpressPeerServer。
- 创建 express 实例
app
,并监听3000
端口。 - 把 ExpressPeerServer 挂载到 express 中,设置
debug
开发模式为 true,这样有更好的错误提示。路径为根目录。 - ExpressPeerServer 挂载之后会返回一个 express 的控制器。
- 把返回的控制器挂载到
/video
路径下,这样/video
路径就是主要的、peer.js 提供的 WebRTC 通信路径。
然后修改一下 package.json 文件,添加一个 script
配置项,里边定义 start
命令值为 nodemon server.js
:
"scripts": {
"start": "nodemon server.js"
},
这样使用 nodemon 运行 server.js 文件后,如果 server.js 中的内容发生变化,它会自动帮助我们重启服务器。
我们现在来运行一下 yarn start
或者 npm start
,看到命令行提示 [nodemon] starting node server.js
就算启动成功了,我们访问一下 http://localhost:3000/video
,看到下方输出结果就说明 peer 也加载成功了:
{
"name": "PeerJS Server",
"description": "A server side element to broker connections between PeerJS clients.",
"website": "https://peerjs.com/"
}
后端代码到这里就编写完成了。下一步就是在前端页面中调用 Peer 相关 的 api,并建立视频通话。
生成用户 ID
在前端中调用后端 Peer 服务可以使用 Peer.js 官方的前端库,可以直接使用 cdn 形式:
<script src="https://unpkg.com/peerjs@1.3.1/dist/peerjs.min.js"></script>
也可以打开上边 src 中的网址,把文件保存到本地,或者在 Github 上下载:
https://github.com/peers/peerjs/blob/master/dist/peerjs.min.js
下载完成之后把它放到 frontend/peer
目录下,然后在 index.html 中,在引入 index.js 的上方,引入 peerjs:
<script src="peer/peerjs.min.js"></script>
<script src="index.js"></script>
接下来打开 index.js 文件,建立与后台 peer 服务的连接:
const peer = new Peer({
host: 'localhost',
port: '3000',
path: '/video'
})
这里直接使用 peer.js 前端库导出的构造函数 Peer(),它接收一个对象作为参数,这里就分别把后台服务的 host、port 和 path 传递进去就可以了,它会返回 peer 实例,后续有关视频通话的操作就主要使用它来实现。
当成功的连接到后台服务之后,我们首先给自己生成一个唯一的用户 ID,就等同于是一个电话号码,那么这里我们可以监听 peer 的 open 事件,当连接打开后,会把生成的用户 ID 返回到事件处理回调函数中,然后我们获取 html 中的 #idText
这个 p 元素来显示自己的 ID:
const idText = document.getElementById('idText')
peer.on('open', id => {
idText.textContent = '我的 id 是:' + id
})
这时在 Live Server 中打开 index.html,就可以看到显示出了 ID,类似于:
我的 id 是:2573c3ae-ba79-404a-b807-2128856ef3c9
多打开几个页面,可以看到每个人的 ID 都不同。
呼叫视频通话
在有了用户 ID 之后,就可以呼叫对方了。这里的逻辑是,用户在输入框输入对方 ID 之后,点击视频通话按钮进行呼叫。那么我们应该先获取视频通话按钮元素,然后监听它的点击事件,在里边发起呼叫:
const joinBtn = document.getElementById('joinBtn')
// 发起呼叫
joinBtn.addEventListener('click', () => {
const peerId = peerIdInput.value
console.log('正在连接:' + peerId)
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => {
const call = peer.call(peerId, stream)
call.on('stream', showVideo)
})
})
在点击按钮的时候,事件处理函数作了如下操作:
- 获取用户输入的对方的 ID。
- 获取当前用户的视频流,然后调用
peer.call()
呼叫对方,peer.call()
需要对方的 ID 和自己的视频流作为参数,然后返回与呼叫有关的实例,保存到 call 中。 - 这时当前用户就开始等待对方应答了,为了简单起见,这里没有做等待的样式。
- 下一步监听 call 的 stream 事件,这个事件会在对方应答后触发,它会返回对方的视频流作为事件处理函数的参数,然后我们使用
showVideo()
函数处理对方的视频流。
showVideo()
函数的代码如下:
const peerVideo = document.getElementById('peerVideo')
function showVideo(stream) {
myVideo.classList.add('rightTop')
peerVideo.srcObject = stream
peerVideo.style.display = 'block'
}
这个函数就是简单的把自己的视频移动到右上角,通过之前定义的 .rightTop
class 样式,然后把对方的视频流放到 #peerVideo
视频组件中,之后把它显示出来(之前设置的是 display: none
隐藏)。现在因为一直是等待对方接听,所以需要有一个应答的处理。
应答视频通话
应答的处理是监听 peer 的 call 事件,然后通过事件参数中与呼叫有关的实例来应答通话:
// 应答呼叫
peer.on('call', call => {
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => {
call.answer(stream)
call.on('stream', showVideo)
})
})
这一步的代码虽然在同一个文件中,但是应该想象为收到视频通话呼叫的对方,代码作了下边的操作:
- 获取自己的视频流。
- 调用
answer()
函数应答视频呼叫,并把自己的视频流传回给呼叫者。这里是当有呼叫时直接进行应答,为了简单起见,没有编写点击应答按钮相关的样式和事件。 - 监听 ** stream ** 事件,这一步和之前的一样,应答后这个事件就会触发,然后同样使用
showVideo()
函数加载视频。