不多说,我们直奔主题。这篇文章教大家如何编写一个视频聊天应用,使已连接的两用户端能共享视频和音频。操作很简单,非常适合JavaScript语言训练——更准确地说是WebRTC技术和Node.js。
什么是WebRTC?
Web Real-Time Communications 网页实时通信,简称WebRTC。WebRTC是一个HTML5规范,它允许用户在浏览器之间直接进行实时通信,不需要任何第三方插件。WebRTC可用于多种情境(比如文件共享),但端对端实时音频和视频通信是其主要功能。本文将着重为大家介绍这两项。
WebRTC所做的就是允许接入设备。你可以借WebRTC来实时使用麦克风、摄像头和分享你的屏幕。
所以,WebRTC可以用最简单的方式在网页中实现音频和视频通信。
WebRTC JavaScript API
WebRTC说起来很复杂,它涉及到很多技术。但建立连接、通信和传输数据的操作是通过一套JS API来实现的,还比较简单。其中主要的API包括:
RTCPeerConnection:创建和导航端对端连接。
RTCSessionDescription:描述连接(或潜在连接)的一端,以及它的配置方式。
navigator.getUserMedia:捕捉音频和视频。
为什么选择Node.js?
若要在两个或多个设备之间进行远程连接,你就需要一个服务器。在这种情况下,你也需要一个处理实时通信的服务器。Node.js是为实时可扩展的应用而构建的。要开发自由数据交换的双向连接应用程序,你可能会用到WebSockets,它允许在客户端和服务器之间建立一个会话窗口。来自客户端的请求会以循环的方式,更准确地说是事件循环进行处理,这时Node.js是我们很好的一个选择,因为它采取 “非阻塞(non-blocking) “的方式来解决请求。这样我们在这该过程中就能实现低延迟和高吞吐量。
如果你对开发微服务感兴趣的话,一定要看看查看我们内含650多位微服务专家意见的2020年微服务状态报告!
思路拓展:我们要创建的是什么?
我们会创建一个非常简单的应用程序,它能让我们将音频和视频流传输到连接的设备——一个基础款视频聊天应用程序。我们会用到的技术有:
Express库,提供静态文件,比如代表用户界面(UI)的HTML文件;
socket.io库,在两个设备之间用WebSockets建立连接;
WebRTC,允许媒体设备(摄像头和麦克风)在连接的设备之间传输音频和视频流。
实现视频会话
我们要做的第一件事是给我们的应用程序提供一个作为UI的HTML文件。让我们通过运行:npm init.js来初始化新的node.js项目。然后,我们需要通过运行:npm i -D typescript ts-node nodemon @types/express @types/socket.io安装一些开发依赖项,运行:npm i express socket.io安装生产依赖项。
之后我们就可以在package.json文件中定义脚本,来运行我们的项目了。
{
"scripts": {
"start": "ts-node src/index.ts",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts"
},
"devDependencies": {
"@types/express": "^4.17.2",
"@types/socket.io": "^2.1.4",
"nodemon": "^1.19.4",
"ts-node": "^8.4.1",
"typescript": "^3.7.2"
},
"dependencies": {
"express": "^4.17.1",
"socket.io": "^2.3.0"
}
}
当我们运行npm run dev命令时,nodemon会监控src文件夹中每个以.ts结尾的文件有无任何变化。现在,我们要创建一个src文件夹。在这个文件夹中,我们会创建两个typescript文件:index.ts和server.ts。
在server.ts中,我们会创建server类,并使其与express和socket.io一起工作。
import express, { Application } from "express";
import socketIO, { Server as SocketIOServer } from "socket.io";
import { createServer, Server as HTTPServer } from "http";
export class Server {
private httpServer: HTTPServer;
private app: Application;
private io: SocketIOServer;
private readonly DEFAULT_PORT = 5000;
constructor() {
this.initialize();
this.handleRoutes();
this.handleSocketConnection();
}
private initialize(): void {
this.app = express();
this.httpServer = createServer(this.app);
this.io = socketIO(this.httpServer);
}
private handleRoutes(): void {
this.app.get("/", (req, res) => {
res.send(`<h1>Hello World</h1>`);
});
}
private handleSocketConnection(): void {
this.io.on("connection", socket => {
console.log("Socket connected.");
});
}
public listen(callback: (port: number) => void): void {
this.httpServer.listen(this.DEFAULT_PORT, () =>
callback(this.DEFAULT_PORT)
);
}
}
为正常运行服务器,我们需要在index.ts文件中创建一个新的Server类实例并调用listen方法。
import { Server } from "./server";
const server = new Server();
server.listen(port => {
console.log(`Server is listening on http://localhost:${port}`);
});
现在,如果我们运行:npm run dev会看到下面这样的情景:
当打开浏览器,输入http://localhost:5000,我们应该注意到左上的 “Hello World “信息。
然后我们就可以在public/index.html中创建一个新的HTML文件了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Dogeller</title>
<link
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,500,700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
</head>
<body>
<div class="container">
<header class="header">
<div class="logo-container">
<img src="./img/doge.png" alt="doge logo" class="logo-img" />
<h1 class="logo-text">
Doge<span class="logo-highlight">ller</span>
</h1>
</div>
</header>
<div class="content-container">
<div class="active-users-panel" id="active-user-container">
<h3 class="panel-title">Active Users:</h3>
</div>
<div class="video-chat-container">
<h2 class="talk-info" id="talking-with-info">
Select active user on the left menu.
</h2>
<div class="video-container">
<video autoplay class="remote-video" id="remote-video"></video>
<video autoplay muted class="local-video" id="local-video"></video>
</div>
</div>
</div>
</div>
<script src="./scripts/index.js"></script>
</body>
</html>
在这个新文件中,我们创建了两个视频元素:一个用于远程视频连接,另一个用于本地视频。你可能已经注意到我们也在导入本地脚本了。现在我们就来创建一个新的文件夹“脚本”,并在这个目录下创建index.js文件。至于样式,你可以从GitHub库中下载它们。
接下来你需要给浏览器提供index.html。首先,你需要告诉express你想提供哪些静态文件。为了实现这一点,我们决定在Server类中实现一个新方法。
private configureApp(): void {
this.app.use(express.static(path.join(__dirname, "../public")));
}
不要忘记在initialize中调用configureApp。
private initialize(): void {
this.app = express();
this.httpServer = createServer(this.app);
this.io = socketIO(this.httpServer);
this.configureApp();
this.handleSocketConnection();
}
当你输入http://localhost:5000后,你应该能看到你的index.html文件在运行。
下一步要实现的是允许摄像头和视频访问并将其流式传输到local-video元素。要做到这一点,你需要打开public/scripts/index.js文件,并用以下方法实现它。
navigator.getUserMedia(
{ video: true, audio: true },
stream => {
const localVideo = document.getElementById("local-video");
if (localVideo) {
localVideo.srcObject = stream;
}
},
error => {
console.warn(error.message);
}
);
当回到浏览器时,界面会出现一个提示请求访问你的媒体设备,在接受请求后,你电脑的摄像头就开始工作了。
更多细节详见A simple guide to concurrency in Node.js and a few traps that come with it。
如何处理socket连接?
接下来我们讲讲如何处理socket连接。我们需要将客户端与服务器连接起来。为此,我们将使用socket.io。在public/scripts/index.js中,添加以下代码:
this.io.on("connection", socket => {
const existingSocket = this.activeSockets.find(
existingSocket => existingSocket === socket.id
);
if (!existingSocket) {
this.activeSockets.push(socket.id);
socket.emit("update-user-list", {
users: this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
)
});
socket.broadcast.emit("update-user-list", {
users: [socket.id]
});
}
}
页面刷新后,电脑会弹出一条消息,显示 “Socket已连接”
然后我们回到server.ts中,把已连接的socket存储在内存中,这只是为了保留唯一连接。所以,我们需要在Server类中添加一个新的私有字段,如下:
private activeSockets: string[] = [];
然后我们需要在socket连接中检查socket是否已经存在。如果不存在,把新的socket推送到内存中,并向已连接的用户发送数据。
this.io.on("connection", socket => {
const existingSocket = this.activeSockets.find(
existingSocket => existingSocket === socket.id
);
if (!existingSocket) {
this.activeSockets.push(socket.id);
socket.emit("update-user-list", {
users: this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
)
});
socket.broadcast.emit("update-user-list", {
users: [socket.id]
});
}
}
你还需要在socket断开连接时及时响应,所以在socket连接中,你需要添加:
socket.on("disconnect", () => {
this.activeSockets = this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
);
socket.broadcast.emit("remove-user", {
socketId: socket.id
});
});
客户端(即public/scripts/index.js)这边,你需要妥善处理那些信息:
socket.on("update-user-list", ({ users }) => {
updateUserList(users);
});
socket.on("remove-user", ({ socketId }) => {
const elToRemove = document.getElementById(socketId);
if (elToRemove) {
elToRemove.remove();
}
});
以下是 updateUserList 函数:
function updateUserList(socketIds) {
const activeUserContainer = document.getElementById("active-user-container");
socketIds.forEach(socketId => {
const alreadyExistingUser = document.getElementById(socketId);
if (!alreadyExistingUser) {
const userContainerEl = createUserItemContainer(socketId);
activeUserContainer.appendChild(userContainerEl);
}
});
}
以及createUserItemContainer函数:
function createUserItemContainer(socketId) {
const userContainerEl = document.createElement("div");
const usernameEl = document.createElement("p");
userContainerEl.setAttribute("class", "active-user");
userContainerEl.setAttribute("id", socketId);
usernameEl.setAttribute("class", "username");
usernameEl.innerHTML = `Socket: ${socketId}`;
userContainerEl.appendChild(usernameEl);
userContainerEl.addEventListener("click", () => {
unselectUsersFromList();
userContainerEl.setAttribute("class", "active-user active-user--selected");
const talkingWithInfo = document.getElementById("talking-with-info");
talkingWithInfo.innerHTML = `Talking with: "Socket: ${socketId}"`;
callUser(socketId);
});
return userContainerEl;
}
需要注意的是,我们给用户容器元素添加了一个可以调用callUser函数的点击监听器——但现在,它可以是一个空的函数。接下来,当运行两个浏览器窗口(其中一个作为私人窗口)时,你应该注意到你的Web应用程序中有两个已经连接的socket。
点击列表中的活跃用户,这时我们需要调用callUser函数。但是在实现之前,你还需要在window对象中声明两个类。
const { RTCPeerConnection, RTCSessionDescription } = window;
我们会在callUser函数用到这两个类:
async function callUser(socketId) {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(new RTCSessionDescription(offer));
socket.emit("call-user", {
offer,
to: socketId
});
}
现在我们要创建一个本地请求并发送给选定的用户。服务器会监听一个叫做call-user的事件、拦截请求并将其转发给选定的用户。让我们用server.ts来实现该操作:
socket.on("call-user", data => {
socket.to(data.to).emit("call-made", {
offer: data.offer,
socket: socket.id
});
});
对于客户端,你需要就call-made事件作出调整:
socket.on("call-made", async data => {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.offer)
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(new RTCSessionDescription(answer));
socket.emit("make-answer", {
answer,
to: data.socket
});
});
之后,在你从服务器得到的请求上设置一个远程描述,并为这个请求创建一个答复。对于服务器端,你只需要将适当的数据传递给选定的用户即可。然后我们再在server.ts里面添加一个监听器。
socket.on("make-answer", data => {
socket.to(data.to).emit("answer-made", {
socket: socket.id,
answer: data.answer
});
});
对于客户端,我们需要处理 answer-made 事件。
socket.on("answer-made", async data => {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.answer)
);
if (!isAlreadyCalling) {
callUser(data.socket);
isAlreadyCalling = true;
}
});
我们可以使用标志isAlreadyCalling,它能帮助确保我们只需调用一次用户。
最后你需要做的是添加本地轨道,包括音频和视频到你的连接端。只有做到这一点,我们才能够与连接的用户共享视频和音频。要做到这一点,我们需要在navigator.getMediaDevice回调中调用peerConnection对象的addTrack函数。
navigator.getUserMedia(
{ video: true, audio: true },
stream => {
const localVideo = document.getElementById("local-video");
if (localVideo) {
localVideo.srcObject = stream;
}
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
},
error => {
console.warn(error.message);
}
);
另外,我们还需要为ontrack事件添加一个适当的处理程序。
peerConnection.ontrack = function({ streams: [stream] }) {
const remoteVideo = document.getElementById("remote-video");
if (remoteVideo) {
remoteVideo.srcObject = stream;
}
};
如图示,我们已经从传递的对象中获取了流,并改变了远程视频中的srcObject来使用接收到的流。所以现在当你点击活跃用户后,你应该建立一个视频和音频连接,像下图这样:
欲了解细节,请参阅:Node.js and dependency injection – friends or foes?
现在你知道如何编写一个视频聊天应用了吧!
WebRTC是一个很大的话题,内容非常庞杂。如果你想了解它的运作原理,就需要花很大功夫。幸运的是,我们可以访问易于使用的JavaScript API,它可以帮助我们创建很简洁的应用程序,例如视频共享、聊天应用程序等等。
如果你想深入了解WebRTC,点击此WebRTC官方文档的链接。另外,我也推荐你阅读MDN的文档说明,它能帮助你更加了解此技术。
闻一
中国移动公布中期业绩:净利润606亿元 同比增长5.6%
8月11日,中国移动今日发布2016年中期财报。今年上半年,中国移动营收3704亿元,同比增长7.1%,净利润606亿元,同比增长5.6%。
中国移动上半年通信服务收入为3254亿元,同比增长6.9%,这一收入主要包括两方面:1、语音收入1200亿元,同比下滑14.2%;2、流量收入1950亿元,同比增长26.7%,其中手机上网流量同比增长133.9%。
用户数方面,移动用户达到8.37亿户,同比增长2.4%。具体到4G,新增4G基站超过20万个,总计达132万个,4G用户总数达到4.29亿,渗透率达到51.2%,4G网络流量占比也提升到88%。宽带方面,上半年净增1081万户,总数达到6584万户。
新闻二
迅雷第二季度净亏损200万美元
北京时间8月10日晚间消息,迅雷(Nasdaq:XNET)公布了截至6月30日的2016财年第二季度未经审计财报。第二季度,迅雷总营收为3810万美元,同比增长22.3%,环比下滑0.9%。
净亏损400万美元,而上一财季净亏损为540万美元。不按照美国通用会计准则,净亏损200万美元,而上一财季净亏损290万美元。截至2016年6月30日,迅雷所持有的现金、现金等价物和短期投资总额为3.91亿美元,相比之下截至2015年12月31日为4.321亿美元。
业绩展望:迅雷预计,2016财年第三季度总营收将达到3800万美元至4200万美元,中间值同比增长19.4%。
新闻三
谷歌新专利:用无人机搭建视频会议系统
据外媒报道,谷歌新获得一项专利显示,未来将用无人机搭建视频会议系统。而且该设备将能解决视频不流畅、相机角度不合适的问题,使用户感觉合作伙伴与自己身处同一间办公室。
ebCodecs 是什么
Web 音视频 API 存在什么问题
音视频技术在 Web 平台上的应用非常广泛,已有许多 Web API 间接调用了编解码器来实现特定功能:
但没有方法可以灵活配置或直接访问编解码器,所以许多应用使用 JS 或 WASM (比如 ffmpeg.wasm)来实现编解码功能,尽管存在诸多缺陷或限制:
这么做的原因是以前的 Web API 在特定场景都存在难以克服的障碍:
总结:目前 API 在特定场景做到简单、够用,但无法实现高效且精细地控制
WebCodecs 设计目标
非 WebCodecs 目标
以上总结于 译 WebCodecs 说明(https://hughfenghen.github.io/posts/2023/10/02/webcodecs-explainer/),让大家快速了解 WebCodecs API 的背景和目标
WebCodecs 能做什么
WebCodecs API 介绍
先了解 WebCodecs API 在视频生产消费链路所处的位置
由图可知 WebCodecs API 提供的能力:
以上就是 WebCodecs 提供的核心 API,新增 API 的数量非常少,主要难点在音视频相关的背景知识。
利用 mp4box.js 解封装 mp4 文件,得到 EncodedVideoChunk 后给 WebCodecs 解码,即可实现 mp4 -> 图像帧。
WebCodecs 不涉及环节
音视频生产消费链路中,由其他 Web API 提供,包括:
相关 Web API
基于底层 API 可以构建的基础能力
基于 Web 平台已有的能力,加上 WebCodecs 提供的编解码能力,能帮助开发者实现那些功能呢?
DEMO 演示及实现
WebCodecs 是相对底层 API,简单功能可能也需要写非常多的辅助代码,可以借助 WebAV 封装的工具函数来快速实现功能
WebAV 基于 WebCodecs,提供简单易用的 API 在浏览器中处理音视频数据
接下来演示 DEMO 效果以及基于 WebAV 的代码实现
1.可控解码
以设备最快的速度解码一个 20s 的视频,并将视频帧绘制到 Canvas 上
可控解码的意义不只是它能实现超快速或逐帧播放视频,而在于它能快速遍历所有帧,这是视频处理的基础
,时长00:02
首先从 WebAV 导出一个 MP4Clip 对象,它只需要一个 MP4文件 URL 进行初始化
然后使用 tick 方法获取到视频帧,再绘制到 canvas 上
while true 表示不做任何等待,所以到底有多快取决于网络下载和设备解码的速度
import { MP4Clip } from '@webav/av-cliper'
// 传入一个 mp4 文件流即可初始化
const clip = new MP4Clip((await fetch('<mp4 url>')).body)
await clip.ready
let time = 0
// 最快速度渲染视频所有帧
while (true) {
const { state, video: videoFrame } = await clip.tick(time)
if (state === 'done') break
if (videoFrame != null && state === 'success') {
ctx.clearRect(0, 0, cvs.width, cvs.height)
// 绘制到 Canvas
ctx.drawImage(videoFrame, 0, 0, videoFrame.codedWidth, videoFrame.codedHeight)
// 注意,用完立即 close
videoFrame.close()
}
// 时间单位是 微秒,所以差不多每秒取 30 帧,丢掉多余的帧
time += 33000
}
clip.destroy()
2. 添加水印
给视频添加随时间移动的半透明文字水印
,时长00:23
先把文字转换成图片,这样很容易借助 css 实现各种文字效果;
然后控制图片按照一定规则移动,这里省略了动画的配置;
动画配置方法跟 css 的动画几乎是一样的,只需提供 0%,50% 特定时机的坐标就行了,WebAV 会自动计算出中间状态的坐标值,来实现动画效果;
最后将 MP4Clip 跟 ImgClip 合成输出一个新的视频流
const spr1 = new OffscreenSprite(
new MP4Clip((await fetch('<mp4 url>')).body)
)
const spr2 = new OffscreenSprite(
new ImgClip('水印')
)
spr2.setAnimation(/* animation config */)
const com = new Combinator()
await com.add(spr1, { main: true })
await com.add(spr2, { offset: 0 })
// com.ouput() => 输出视频流
3. 绿幕抠图
带绿幕的数字人形象与背景图片合成视频,使用 WebGL 对每帧图像进行处理,将人物背景修改为透明效果
抠图实现参考文章:WebGL Chromakey 实时绿幕抠图(https://hughfenghen.github.io/posts/2023/07/07/webgl-chromakey/)
,时长00:06
// 创建抠图工具函数
const chromakey = createChromakey(/* 绿幕抠图配置 */)
// 背景绿幕的测试视频
const clip = new MP4Clip((await fetch('<mp4 url>')).body)
// MP4 的每一帧 都会经过 tickInterceptor
clip.tickInterceptor = async (_, tickRet) => {
if (tickRet.video == null) return tickRet
return {
...tickRet,
// 抠图之后再返回
video: await chromakey(tickRet.video)
}
}
4. 花影
在浏览器中运行的视频录制工具,可用于视频课程制作、直播推流工作台
视频演示视频课程制作的基本操作,包含 “添加摄像头、分享屏幕、修改素材层级、剪切视频片段、预览导出视频” 五个步骤
,时长00:53
WebCodecs 的应用场景
应用场景预测
视频生产:从零到一
由于缺失编码能力,导致 Web 端少有视频生产工具;
现有的 Web 视频剪辑工具都强依赖服务端能力支持,交互体验存在优化空间;
在 Web 页面借助 Canvas 制作动画是非常简单的,借助 WebCodecs 的编码能力,现在就能将动画快速保存为视频。
视频裁剪、添加水印、内嵌字幕等基础视频剪辑能力,没有 WebCodecs 都是难以实现的,WebCodecs 将填补该领域的空白。
视频消费:能力增强
借助 HTMLMediaElement、MSE,Web 平台的视频消费应用已经非常成熟;
以上 API 虽然简单易用,但无法控制细节,常有美中不足之感
比如,缓冲延迟控制、逐帧播放、超快速播放、解码控制等
WebCodecs 将支持构建更强、体验更好的视频消费应用
算力转移:成本体验双赢
目前 Web 使用的音视频服务,其处理过程都是在服务器上完成的
比如,众多在线视频处理工具提供的:压缩(降低分辨率、码率)、水印、变速、预览图 功能
处理流程:用户上传视频 -> 服务器处理 -> 用户下载视频;
整个过程消耗了服务器的计算成本、带宽成本,用户上传下载的等待时间
WebCodecs 能让更多的任务在本地运行,不仅降低了服务运营成本,还能提升用户体验
案例分享
没有 WebCodecs 以上的工具已经存在了,为什么相信它们会应用 WebCodecs?
首先,有了 WebCodecs 之后这些工具能做到体验更好、更便宜、迭代更快;
再结合以往经验和 Web 平台所具备优势,相信 WebCodecs 未来会得到广泛应用
分享两个例子
1. 用户视频消费行为变化
2. 富文本编辑
Web 开放了几个核心 API,让大部分文字编辑转移到线上,产生大量优秀的知识管理应用
借助 Web 的易访问性、搭配协同编辑,将生产沟通效率提升了一个等级
还有大量产品案例:Notion、Figma、VSCode...
总结:一旦 Web 平台具备某个领域的基础能力,相关产品不可避免的 Web 化
WebCodecs 的优势与限制
优势
性能
ffmpeg.wasm 最大的障碍就是性能问题,导致难以大规模应用,主要是因为它不能使用硬件加速所以编解码非常慢
测试简单的视频编码场景,WebCodecs 的性能是 ffmpeg.wasm 的 20 倍
Web 平台
Web 平台天然具有的优势:跨平台、便捷性、迭代效率
再加上底层能力越来越完善,已具备构建大型、专业软件的条件;
相信 WebCodecs 也能凭借 Web 平台的加持,获得更大的应用空间
限制
生态不成熟只需要时间和更多开发者的积极参与,一般 to B 产品对兼容性会更宽容一些,to C 的产品可以降级到服务端实现
比较麻烦的是 Web 平台提供的编解码器相对 Native 直接调用来说,还是有一些差距
如果需要自定义编解码器,或对编解码器的参数配置有非常高的要求,技术方案选择的时候需要慎重考虑 WebCodecs
愿景
附录
作者:刘俊
来源:微信公众号:哔哩哔哩技术
出处:https://mp.weixin.qq.com/s/d28Xq9dticMdbO0s0N5MzA
*请认真填写需求信息,我们会在24小时内与您取得联系。