前言
项目的 Web 端是 Vue3 框架,后端是 GO 框架。需要实现将客户端的本地摄像头媒体流推送至服务端,而我自己从未有媒体流相关经验,最初 leader 让我尝试通过 RTSP 协议推拉流,我的思路就局限在了 RTSP 方向。
最初使用的服务端流媒体处理服务器是RTSPToWeb
GitHub - deepch/RTSPtoWeb:RTSP 流到 WebBrowser
RTSPtoWeb 可以将 RTSP 流转换为可在 Web 浏览器中使用的格式,如 MSE(媒体源扩展)、WebRTC 或 HLS。
我打算在 Web 端将本地摄像头数据流以RTSP协议发送至服务端,通过RTSPtoWeb处理为Web可以使用的格式。客户端的推流软件我选择FFmpeg,我找到了可以在Vue中使用FFmpeg的方法:
FFmpeg——在Vue项目中使用FFmpeg(安装、配置、使用、SharedArrayBuffer、跨域隔离、避坑...)_vue ffmpeg-CSDN博客
在浏览器中我们是无法直接使用 FFmpeg 软件的,但好在有个东西叫 FFmpeg.wasm,它可以让 FFmpeg 的功能在浏览器中使用。我们在 Vue 项目中使用 FFmpeg.wasm 来代替手动输入命令行操作的 FFmpeg 软件。FFmpeg.wasm 是 FFmpeg 的纯 WebAssembly 接口,可以在浏览器内录制音频和视频,并进行转换和流式传输。但后面实际操作我发现,现在FFmpeg.wasm在0.12.0版本之后不再支持 NodeJS
FAQ | ffmpeg.wasm (ffmpegwasm.netlify.app)
但使用 FFmpeg.wasm 旧版本时我遇到好多报错。。。我第一次写前端能力属实不足,最后选择放弃了这条思路。。。有能力或者使用的不是 NodeJS 的小伙伴可以用 FFmpeg.wasm 在 Web 推流,很方便好用。
后面我又有一个歪点子,用 GO 编写从命令行端操作 FFmpeg 推拉流 API ,再打包为 exe 可执行文件,运行在客户端。但在小组开会后,这个方案被毙了。。。因为没有考虑客户需求,首先客户在 PC 端访问我们的 Web ,不仅需要下载 FFmpeg ,现在还得多下载一个 exe 文件;其次是考虑客户要在移动端使用。第一次实习,第一次做客户项目,考虑的没有很全面。
后面我发现为什么不直接用WebRTC呢?这可是专门用来解决Web媒体流的好东西!!!
于是我更改了方案,将mediamtx作为新的服务器,
GitHub - bluenviron/mediamtx: Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.
mediamtx支持多种协议,可以解决很多需求,强推!!!
WebRTC简介
搞懂WebRTC ,看这一篇就够了-CSDN博客
WebRTC提供了基础的前端功能实现,仅仅通过JavaScript,Web端即可实现点对点的视频流、音频流或者其他数据的传输,所用到的知识点如下:
WHIP / WHEP 协议
WTN普及(一)WHIP/WHEP标准信令 - 知乎 (zhihu.com)
WebRTC(WebRTC-HTTP Ingestion Protocol)通过 WHIP 协议将音视频流从客户端传输到服务器。WHEP(WebRTC-HTTP Egress Protocol)允许基于浏览器的流媒体内容的低延迟观看。
WHIP / WHEP 不仅仅可以用作流媒体的传输。在未建立 WebRTC 之前,通讯双方需要商议彼此的媒体协议,也可能无法访问彼此的 IP,故我们需要信令服务器传递双方的 SDP 和 candidates 信息,而俩个协议在 WebRTC 之上增加了一个简单的信令层解决了这个问题,我们可以通过 WebStock 或者 http 向信令层发送信息。
SDP 协议
WebRTC通话原理(SDP、STUN、 TURN、 信令服务器)_webrtc stun服务器-CSDN博客
通信双方需要发送媒体流,而视频和音频都涉及到编码格式,故双方需要先协商统一编码格式,保证媒体流顺利发送。
SDP(Session Description Protocol)是一种用于描述多媒体会话的格式。它包含了会话的媒体类型、格式、传输协议和网络信息等。SDP 在 WebRTC 中用于协商音视频通话的各种参数,确保两个端点可以兼容并顺利进行通信。
NAT 穿透
NAT(Network Address Translation,网络地址交换)主要解决 IPv4 地址不够用和安全问题。通过多台主机共用一个公网 IP 地址来减缓 IPv4 地址不够用的问题。使用 NAT 后,主机隐藏在内网,这样黑客很难访问到内网主机,从而达到保护内网主机的目的。NAT 其实就是一种地址映射技术,它在内网地址与外网地址之间建立了映射关系。
通讯双方不在一个局域网内,则无法访问直接彼此的 IP,故需要 NAT 将双方的内网 IP 转换为 公网 IP,以便于双方可以互相访问。为实现穿透,我们需要用到 ICE(Interactive Connectivity Establishment,交互式连接创建)建立双方的网络连接。
ICE
WebRTC技术文档 -- 5.ICE(笔记)_webrtc ice-CSDN博客
ICE 是一种基于 offer/answer 模式解决 NAT 穿越的协议集合。它结合STUN和TURN协议,使客户端无需考虑网络位置和NAT类型即可动态发现最优传输路径。
实现的具体过程为:收集网络信息 Candidate、交换 Candidate、按优先级尝试连接。Candidate指可连接的候选者。每个候选者是包含address(IP地址)、port(端口号)、protocol(传输协议)、CandidateType(Candidate类型)、ufrag(用户名)等内容的信息集。WebRTC将Candidate分为host、srflx、prflx和relay四类,优先级依次由高到低。
STUN / TRUN
WebRTC学习之路---TURN/STUN服务原理及搭建_webrtc 客户端建立连接 stun-CSDN博客
ICE 使用 STUN Binding Request 和 Response,来获取公网映射地址和进行连通性检查。客户端向 STUN 服务器发送请求,STUN 服务器返回其看到的客户端的公共地址和端口。这样,客户就可以告诉其他对等方(Peer)自己的公共地址,以便建立直接连接。
ICE 使用 TURN 协议作为 STUN 的辅助,在点对点穿越失败的情况下,借助于 TURN 服务的转发功能,来实现互通。客户端首先尝试使用 STUN 获取公共地址。如果双方无法通过公共地址直接连接,客户端可以将媒体发送到 TURN 服务器,由 TURN 服务器转发到对等方。这种方式虽然增加了延迟,但可以保证连接的建立。
WebRTC 流程图
WebRTC的建立如下图:
在下面代码中,Web端(client)与远端(mediamtx 服务器)通过 HTTP 请求进行交互实现信令。
具体实现
安装运行mediamtx
mediamtx 我们只需要直接下载独立二进制文件运行即可。
下载地址:Releases · bluenviron/mediamtx (github.com)
windows 系统下载圈出来的即可,解压后里面有一个 exe 文件,打开即可
通过WebRTC发送媒体流的示例网址
注意:以下项目和 mediamtx 都运行在一个 PC 上
mediamtx提供了一个发送媒体流的示例网址的源代码:
mediamtx/internal/servers/webrtc/publish_index.html at main · bluenviron/mediamtx · GitHub
URL:localhost:8889/1/publish
其中1代表的是路径,也是后面查询媒体流和保存媒体流的路径,示例页面如下:
我们看到video device为OBS,数据流的默认选项是OBS虚拟摄像头,当有外部设备接入,如USB摄像头,会默认选择为 USB 摄像头设备。video device 还可选择 screen ,即本地屏幕推流。
其他的选项依次是视频的编码、波特率、帧率、分辨率和音频的设备、编码、波特率、优化
我接入设备后,选项都是默认的 publish 画面如下:
mediamtx 的info信息为:
WebRTC 创建新的 session
对等连接(peer connection)成功建立;本地(Web)候选地址和远端(mediamtx)候选地址
[path 1] 代表录制的路径,这里会录制是因为我在 mediamtx.yaml 文件中配置了录制,其他配置还要保存路径、格式、最大录制时间、录制片段时间和自动删除时间
正在录制音视频轨道,Opus 格式的音频轨道 AV1 格式的视频轨道。
Vue3中实现WebRTC发送媒体流
根据示例网址的源代码,我们可以修改 WebRTC 代码格式如下:
HTML 元素:
<template> <div> <video ref="videoElement" autoplay playsinline></video> </div></template>
导包和定义的参数:
import { ref } from 'vue';// 其中1为路径// whip 用于身份验证const webrtcUrl = http://localhost:8889/1/whip; // 1代表路径,可改为你自己的路径const retryPause = 2000;const videoElement = ref<HTMLVideoElement | null>(null);let pc: any = null;let stream: any = null;let restartTimeout: number | null = null;let sessionUrl = '';let offerData: OfferDescription;let queuedCandidates: RTCIceCandidate[] = [];interface OfferDescription { iceUfrag: string; // 唯一标识 sdp 的短字符串 icePwd: string; // sdp 对应密码 medias: any[]; // 媒体描述,编码率等信息}
主函数:
const onPublish = () => { postMessage('connecting'); const videoId = videoForm.device; const audioId = audioForm.device; let videoOpts: { deviceId: string } | boolean = false; let audioOpts = { deviceId: '', autoGainControl: true, //自动增益控制 echoCancellation: true, //启用回声消除 noiseSuppression: true, //噪音抑制 }; if (videoId !== 'screen') { if (videoId !== 'none') { videoOpts = { deviceId: videoId, }; } if (audioId !== 'none') { audioOpts.deviceId = audioId; const voice = audioForm.voice; if (!voice) { // 如果没有声音选择,则关闭声音 audioOpts.autoGainControl = false; audioOpts.echoCancellation = false; audioOpts.noiseSuppression = false; } } navigator.mediaDevices .getUserMedia({ video: videoOpts, audio: audioOpts, }) .then((str) => { stream = str; if (videoElement.value) { //将得到的媒体流赋予videoElement,显示在 HTML 元素中 videoElement.value.srcObject = stream; } requestICEServers(); }) .catch((err) => { onError(err.toString(), false); }); } else { navigator.mediaDevices .getDisplayMedia({ video: { width: { ideal: parseInt(videoForm.width) }, height: { ideal: parseInt(videoForm.height) }, frameRate: { ideal: parseInt(videoForm.framerate) }, }, audio: true, }) .then((str) => { stream = str; if (videoElement.value) { videoElement.value.srcObject = stream; } requestICEServers(); }) .catch((err) => { onError(err.toString(), false); }); }};
Web 端获取 STUN 服务器,收集本地网络信息(candidate),通过 ICE 服务器获取 Web 端的公网ip,并添加至 candidate
const requestICEServers = () => { //请求 STUN 服务器 fetch(webrtcUrl.value, { method: 'OPTIONS', }) .then((res) => { // 通过返回值中的头获取 STUN 服务器信息 // STUN 服务器信息在yaml文件中设置 // 我在mediamtx.yaml设置 STUN 为 url: stun:stun.l.google.com:19302 pc = new RTCPeerConnection({ iceServers: linkToIceServers(res.headers.get('Link')), }); pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => onLocalCandidate(evt); pc.oniceconnectionstatechange = () => onConnectionState(); stream.getTracks().forEach((track: any) => { pc.addTrack(track, stream); }); createOffer(); }) .catch((err) => { onError(err.toString(), true); });};const linkToIceServers = (links: any): any => { if (links === null) return []; // 检查 `links` 是否为 null return links .split(', ') .map((link: any) => { const m = link.match( /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i ); if (!m) return null; // 如果没有匹配,返回 null const ret = { urls: [m[1]], } as { urls: any[]; username?: string; credential?: string; credentialType?: string; }; if (m[3] !== undefined) { ret.username = unquoteCredential(m[3]); ret.credential = unquoteCredential(m[4]); ret.credentialType = 'password'; } return ret; // 始终返回 ret }) .filter(Boolean); // 筛选掉 null 值};// 带有引号的凭证字符串解析为 JSON 格式const unquoteCredential = (v: string) => JSON.parse(`"${v}"`);// 监听并收集本地的网络信息 candidateconst onLocalCandidate = (evt: any) => { if (restartTimeout !== null) { return; } // 检测到新的 candidate if (evt.candidate !== null) { // 代表尚未建立连接 if (sessionUrl === '') { // 将 candidate 加入队列 queuedCandidates.push(evt.candidate); } else { sendLocalCandidates([evt.candidate]); } }};// 发送 SDP 主要信息和网络信息 candidate 完成WebRTC 建立const sendLocalCandidates = async (candidates: any) => { await fetch(sessionUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/trickle-ice-sdpfrag', 'If-Match': '*', }, body: generateSdpFragment(offerData, candidates), }) .then((res) => { if (res.status !== 204) { throw new Error(`bad status code ${res.status}`); } }) .catch((err) => { onError(err.toString(), true); });};// 使用 SDP 主要信息和网络信息 candidate生成片段const generateSdpFragment = (od: any, candidates: any) => { const candidatesByMedia: any = {}; for (const candidate of candidates) { const mid = candidate.sdpMLineIndex; if (candidatesByMedia[mid] === undefined) { candidatesByMedia[mid] = []; } candidatesByMedia[mid].push(candidate); } let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n' + 'a=ice-pwd:' + od.icePwd + '\r\n'; let mid = 0; for (const media of od.medias) { if (candidatesByMedia[mid] !== undefined) { frag += 'm=' + media + '\r\n' + 'a=mid:' + mid + '\r\n'; for (const candidate of candidatesByMedia[mid]) { frag += 'a=' + candidate.candidate + '\r\n'; } } mid++; } return frag;};
Web 端和远端(mediamtx)交换 SDP
// 创建 SDP ,描述本端浏览器支持哪些能力const createOffer = () => { pc.createOffer() .then((offer: any) => { offerData = parseOffer(offer.sdp); if (pc) { // offer 设置为本地描述 pc.setLocalDescription(offer) .then(() => { sendOffer(offer.sdp); }) .catch((err: any) => { onError(err.toString()); }); } }) .catch((err: any) => { onError(err.toString()); });};// 解析 SDP ,得到 SDP 中的主要信息const parseOffer = (offer: any) => { const ret: OfferDescription = { iceUfrag: '', icePwd: '', medias: [], }; for (const line of offer.split('\r\n')) { if (line.startsWith('m=')) { ret.medias.push(line.slice('m='.length)); } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { ret.iceUfrag = line.slice('a=ice-ufrag:'.length); } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) { ret.icePwd = line.slice('a=ice-pwd:'.length); } } return ret;};// 发送 SDP 到远端(mediamtx)const sendOffer = async (offer: any) => { console.log('sendOffer', offer); offer = editOffer(offer); await fetch( webrtcUrl.value + `?video-device=${videoForm.device}`, { method: 'POST', headers: { 'Content-Type': 'application/sdp', }, body: offer, } ) .then((res) => { switch (res.status) { case 201: break; case 400: return res.json().then((e) => { throw new Error(e.error); }); default: throw new Error(`bad status code ${res.status}`); } const locationHeader = res.headers.get('location'); if (!locationHeader) { throw new Error('Location header is missing'); } sessionUrl = new URL(locationHeader, 'http://localhost:8889').toString(); return res.text().then((answer) => onRemoteAnswer(answer)); }) .catch((err) => { onError(err.toString(), true); });};const editOffer = (sdp: any) => { console.log('editOffer', sdp); const sections = sdp.split('m='); console.log('sections', sections); for (let i = 0; i < sections.length; i++) { if (sections[i].startsWith('video')) { // 设置 SDP 中 vedio 的编码率 sections[i] = setCodec(sections[i], videoForm.codec); } else if (sections[i].startsWith('audio')) { // 设置 SDP 中 audio 的编码率和波特率 sections[i] = setAudioBitrate( setCodec(sections[i], audioForm.codec), audioForm.bitrate, audioForm.voice ); } } return sections.join('m=');};// // 接受远端 SDP信息的 Answerconst onRemoteAnswer = (sdp: string) => { if (restartTimeout !== null) { return; } sdp = editAnswer(sdp); // 保存远端 SDP信息的 Answer pc.setRemoteDescription( new RTCSessionDescription({ type: 'answer', sdp, }) ) .then(() => { if (queuedCandidates.length !== 0) { sendLocalCandidates(queuedCandidates); queuedCandidates = []; } }) .catch((err: any) => { onError(err.toString()); });};const editAnswer = (sdp: any) => { const sections = sdp.split('m='); for (let i = 0; i < sections.length; i++) { if (sections[i].startsWith('video')) { sections[i] = setVideoBitrate(sections[i], videoForm.bitrate); } } return sections.join('m=');};
设置 vedio 和 audio 编码格式
// 设置 video 波特率const setVideoBitrate = (section: any, bitrate: any) => { let lines = section.split('\r\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('c=')) { lines = [ ...lines.slice(0, i + 1), 'b=TIAS:' + (parseInt(bitrate) * 1024).toString(), ...lines.slice(i + 1), ]; break; } } return lines.join('\r\n');};//设置编码格式const setCodec = (section: any, codec: any) => { const lines = section.split('\r\n'); const lines2 = []; const payloadFormats = []; for (const line of lines) { if (!line.startsWith('a=rtpmap:')) { lines2.push(line); } else { if (line.toLowerCase().includes(codec)) { payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]); lines2.push(line); } } } const lines3 = []; let firstLine = true; for (const line of lines2) { if (firstLine) { firstLine = false; lines3.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' ')); } else if (line.startsWith('a=fmtp:')) { if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) { lines3.push(line); } } else if (line.startsWith('a=rtcp-fb:')) { if ( payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0]) ) { lines3.push(line); } } else { lines3.push(line); } } return lines3.join('\r\n');};const setAudioBitrate = (section: string, bitrate: string, voice: any) => { let opusPayloadFormat = ''; let lines = section.split('\r\n'); for (let i = 0; i < lines.length; i++) { if ( lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/') ) { opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]; break; } } if (opusPayloadFormat === '') { return section; } for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) { if (voice) { lines[i] = 'a=fmtp:' + opusPayloadFormat + ' minptime=10;useinbandfec=1;maxaveragebitrate=' + (parseInt(bitrate) * 1024).toString(); } else { lines[i] = 'a=fmtp:' + opusPayloadFormat + ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=' + (parseInt(bitrate) * 1024).toString(); } } } return lines.join('\r\n');};
错误处理函数:
const onError = (err: string, retry?: boolean) => { if (!retry) { console.error('err:', err); } else { if (restartTimeout === null) { console.error(err + ', retrying in some seconds'); if (pc !== null) { pc.close(); pc = null; } restartTimeout = window.setTimeout(() => { restartTimeout = null; startTransmit(); }, retryPause); if (sessionUrl) { fetch(sessionUrl, { method: 'DELETE', }); } sessionUrl = ''; // 清空 STUN 服务器候选队列 queuedCandidates = []; } }};
注意
关于 vedio 设置
const videoForm = { device: '', // 设备ID:none,screen(屏幕),空值默认为外部设备,若没有则为OBS虚拟设备 codec: 'h264/90000', // 编解码器格式有 bitrate: '10000', // 比特率 framerate: '30', // 帧率 width: '1920', height: '1080',};
例如其中 codec 的设置为 h264/90000,其中90000是时钟频率,用于时间戳的单位,它表示每秒钟可以产生90000个时间单位,用于确保视频流和音频流的同步。若设置为 h264 ,则会导致发送的 SDP 中缺少编码协议,导致 WebRTC 建立失败。
搜集到的网络信息candidates
host候选:
candidate:1799829579 1 udp 2122260223 10.102.24.113 51222 typ host generation 0 ufrag 1Phf network-id 1
10.102.24.113 是我电脑内WSL虚拟网络适配器的IP
a=candidate:66318701 1 udp 2122194687 192.168.64.1 51223 typ host generation 0 ufrag 1Phf network-id 2
192.168.64.1 电脑以太网适配器的地址
这些是主机候选,表示的是客户端本地网络中的IP地址(如10.102.24.113
和192.168.64.1
)。这些地址通常是私有IP地址,无法被公网直接访问。
添加STUN/TRUN
srflx候选:
a=candidate:2861133569 1 udp 1686052607 221.xx.xx.xxx 51222 typ srflx raddr 10.102.24.113 rport 51222 generation 0 ufrag 1Phf network-id 1
这个候选是通过STUN服务器获取的反射候选(srflx),显示外部的可路由地址(即公网IP),在这个例子中为221.xx.xx.xxx。这意味着 STUN 服务器成功返回了一个公网 IP 地址。
结果
当 mediamtx 反馈下面 info,即代表 WebRTC 连接和传输媒体流成功
这样媒体流就可以保存在 mediamtx 服务器上了。服务器上查询、转发媒体流等方法均可以在手册中获取。
GitHub - bluenviron/mediamtx: Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.
菜鸟第一次写文章,对自己项目中用到的模块,通过查阅和学习完成自己的见解,如果可以帮助到你,请帮忙点点赞。可能有用词不当和错误的地方,请大家斧正,感谢阅读!!!