直播技术研究(FFmpeg,JavaCV,SRS,WebRTC知识大杂烩)
在说直播技术前,需要先对音视频及其格式(.mp4,.flv)有一定的了解,然后了解相关的协议信息(WebRTC),音视频处理工具(FFmpeg),主流的流媒体框架(如SRS).
- 本文对上面涉及的技术都做了一定程度的总结,希望可以给对这方面感兴趣的兄弟姐妹们一点启发!
- 看懂本文代码需要对Java,SpringBoot以及前端Vue3有一定的基础!
音视频前置知识
可以直接看下面链接的内容(更详细!),然后跳过这一部分。 参考资料
色彩空间 RGB(红绿蓝) vs YUV(Luminance亮度,Hue色调,Saturation色饱和度)
-
视频领域文件主要采用YUV这种色彩空间模式 亮度:Y,色度:UV
-
经过大量研究实验表明,视觉系统 对 色度 的敏感度 是远小于 亮度的。 YUV中,Y是最重要的,UV相对不重要,因此UV可以压缩【图片压缩级别】
-
RGB转为YUV
# 从RGB提取Y
Y=Kr∗R+Kg∗G+Kb∗B
音视频压缩标准
- 常见的压缩标准有以下: 1,JPEG 标准,用于单张图片压缩。标准文档 ISO/IEC 10918-1 2,H.262 标准,用于视频编解码,标准文档 ISO/IEC 13818-2 3,H.263 标准,用于视频编解码。 4,H.264 标准,在 2022年 目前是应用非常广泛的标准。 5,VP9,Google 出的视频编解码标准。 6,AVS,中国的视频压缩标准。
- 现在主流的H.264标准压缩量已经十分可观。
压缩算法基础
总的来说,常用的编码方式分为三种:
- 变换编码:消除图像的帧内冗余。涉及到图像学里面的两个概念:空域和频域。空域就是我们物理的图片,频域就是将物理图片根据其颜色值等映射为数字大小。而变换编码的目的是利用频域实现去相关和能量集中。常用的正交变换有离散傅里叶变换,离散余弦变换等等。
- 运动估计和运动补偿:消除帧间冗余。视频压缩还存在时间上的关联性。例如,针对一些视频变化,背景图不变而只是图片中部分物体的移动,针对这种方式,可以只对相邻视频帧中变化的部分进行编码。
- 熵编码:提高压缩效率,熵编码主要是针对码节长度优化实现的。原理是针对信源中出现概率大的符号赋予短码,对于概率小的符号赋予长码,然后总的来说实现平均码长的最小值。编码方式(可变字长编码)有:霍夫曼编码、算术编码、游程编码等。
音视频格式分类
MP4
点播场景,什么是点播,就是视频已经录好了,放在服务器,客户端按需拉取一小端内容播放,不需要下载全部的视频内容。 点播场景比较适合用 MP4 格式,因为 MP4 格式定义了 stts 索引表以及一些相关的数据结构,可以很快的跳转,例如 跳转 某个时间点播放,MP4 格式会比 FLV 快很多。(补充:FLV 可以额外添加 keyframeindex 加快跳转速度)
FLV
直播场景,MP4 格式的box结构要全部视频录完才能生成,而直播是不知道什么时候结束的。而 FLV 是一种渐进式的格式,非常适合用于直播。
直播整体业务
推送方:采集视频->视频滤镜美化->编码压缩成视频流->推流 播放端:拉流->解码->播放
协议
协议选型:参考腾讯云直播服务
腾讯直播云服务器提供rtmp,webrtc,srt三种协议的支持。
直播中采用的协议
推送协议:RTMP
可以借助软件ffmpeg或者OBS推送流
拉取协议:HTTP-FLV
RTMP本质是可以拉取,但是FLash插件被禁用。 现在浏览器需要flv.js实现对rtmp推流的拉取
推送协议:WebRTC
WebRTC是一个开源项目,以它本身为基础命名的协议叫WebRTC协议。
WebRTC是现在主流浏览器都会内置的插件,如chrome浏览器可访问地址查看插件信息:chrome://webrtc-internals/
提供了摄像头,录音推流相关的一些api.
使用的协议是独立与axios(HTTP协议),websorket协议外的第3种协议,。
不适合直播使用的协议HLS(延时30s+,经过优化最低也要5s-10s)
Http Live Streaming使用.m3u8 .ts这两种格式的文件形式。 .m3u8(一个.m3u8文件)作为索引文件,主要作用就是确定包的顺序。 .ts(一般多个ts文件) 视频文件
SRS服务器
启动
# 在/srs4.0/trunk/文件夹下执行以下命令启动
./objs/srs -c conf/srs.conf
管理页面
# 主页面
http://192.168.5.111:8080/
# 运行时系统参数,视频流,配置等信息的页面
http://192.168.5.111:1985/console/en_index.html#/summaries?schema=http&host=192.168.5.111&port=1985
# 播放器测试页面
http://192.168.5.111:8080/players/srs_player.html
FFmpeg
libavformat:音视频格式生成解析
libavcodec:音视频编解码
libavfilter:滤镜工具filter
windows-FFmpeg
FFmpeg命令基础
命令格式
1.封装格式转换
# 以下操作涉及->源文件解码->读取写入->目标文件编码的过程
ffmpeg -i b.flv b.mp4
# 如果两种协议使用同种编码格式,未避免编解码带来的性能消耗
## 如flv,mp4都采用的编码为H.264 此时应该使用以下命令
ffmpeg -i b.flv -c copy b.mp4
[没有-c copy]:
![-c copy]
可以看到两种的速度差距!
2.编码格式转换
# -c:v hevc 指定video视频的编码格式转为hevc(H.265)
# -c:a copy audio音频不转换直接复制
ffmpeg -i juren.mp4 -c:v hevc -c:a copy juren-h265.mp4
JavaCV
JavaCV集成了FFmpeg与OpenCV: FFmpeg音视频处理工具+OpenCV机器学习相关的工具
依赖
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>1.5.2</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg-platform</artifactId>
<version>4.2.1-1.5.2</version>
</dependency>
【待补充】使用
最简单的Demo(1.0版本)
启动SRS服务器
./objs/srs -c conf/srs.conf
ffmpeg推流
- 说明:这里地址
rtmp://服务器IP地址/live/资源名称前端http-flv协议的地址应为:http://服务器IP地址/live/资源名称.flv(1.协议为http 2.后缀加上.flv)
浏览器拉流
flv.js库,url地址直接写死
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title></title>
</head>
<body>
<script src="https://cdn.bootcss.com/flv.js/1.4.0/flv.min.js"></script>
<video id="videoElement" style="width: 80%" controls="controls"></video>
<script>
if (flvjs.isSupported()) {
var videoElement = document.getElementById('videoElement')
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: 'http://192.168.5.111:8080/live/livestream.flv'
})
flvPlayer.attachMediaElement(videoElement)
flvPlayer.load()
flvPlayer.play()
}
</script>
</body>
</html>
Demo(1.1-fail失败版本)
启动SRS服务器
第一步仍然启动SRS服务器
推流
推流这个部分在版本1的Demo使用ffmpeg模拟。
但是如果要提供真实的直播服务,需要能提供用户网页端摄像头,音频推流到服务器的接口才行。
具体的实现
推流:前端->Java后端(多线程)->SRS服务器
后端实现接口
注意:需要实现线程池!
技术实现方案一
在服务器上安装好ffmpeg,
然后使用java的Runtime.getRuntime().exec("ffmpeg -re -i 视频文件.flv -c:v copy -c:a copy -f flv -y rtmp的推流地址")
技术实现方案二:JavaCV
参考GitHub项目:`ffmpeg-web-pusher
前端获取摄像头音频信息实现推流
ffmpeg 命令只能推送文件到服务器
根据目前找的资料,前端获取摄像头麦克风流navigator.mediaDevices.getUserMedia()的stream没有比较简单的方式推送到后端!
Demo(2.0版本,WebRTC)
架构
推流:前端->SRS服务器 拉流:SRS服务器->前端
启动SRS服务器 (指定环境)
env CANDIDATE="192.168.5.111" ./objs/srs -c conf/rtc.conf
前端代码(Vue3)
<template>
<div id="box">
<!-- 设置自动播放,否则不会显示视频流画面 -->
<video id="video" ref="video" autoplay></video>
<canvas style="display: contents;"></canvas>
<div id="btn" >
<button ref="button_one" @click="publish">开始直播</button>
<button ref="button_two" @click="close" >停止直播</button>
<button ref="button_three" @click="stopAudio" >关闭声音</button>
<button ref="button_four" @click="startAudio" >开启声音</button>
<button ref="button_five" @click="play" >播放直播</button>
</div>
<video id="video2" ref="video2" autoplay></video>
</div>
</template>
<script>
export default {
name: 'webrtc2',
data() {
return {
videoStream:null,
videoElement:null,
pc:null,
audioTrack:null,
audioSender:null,
}
},
mounted() {
this.$refs.button_one.disabled=false;
this.$refs.button_two.disabled=true;
this.$refs.button_three.disabled=true;
this.$refs.button_four.disabled=true;
this.$refs.button_five.disabled=true;
},
methods:{
async publish(){
if(this.pc!==null&& this.pc!==undefined){
console.log("已开始推流");
return;
}
var httpURL = "http://192.168.5.111:1985/rtc/v1/publish/";
var webRTCURL = "webRTC://192.168.5.111/live/10";
var constraints = {
audio: {
echoCancellation : true, // 回声消除
noiseSuppression : true, // 降噪
autoGainControl : true // 自动增益
},
video: {
frameRate : { min : 30 }, // 最小帧率
width : { min : 640, ideal : 1080}, // 宽度
height : { min : 360, ideal : 720}, // 高度
aspectRadio : 16/9 // 宽高比
}
}
// 通过摄像头、麦克风获取音视频流
this.videoStream = await navigator.mediaDevices.getUserMedia(constraints);
// 获取video元素
this.videoElement = document.querySelector("#video")
//video播放流数据
this.videoElement.srcObject = this.videoStream;
// 静音
this.videoElement.volume=0;
// 创建RTC连接对象
this.pc = new RTCPeerConnection();
// RTCPeerConnection方法addTransceiver()创建一个新的RTCRtpTransceiver,并将其添加到与RTCPeerConnection关联的收发器集中。
// 每个收发器代表一个双向流,RTCRtpSender和RTCRtpReceiver都与之相关联。
// 注意添加顺序为audio、video,后续RTCPeerConnection创建offer时SDP的m线顺序遵循此顺序创建,SRS自带的信令服务器响应的SDP中m线总是先audio后video。
// 若本端SDP和远端SDP中的m线顺序不一直,则设置远端描述时会异常,显示offer中的m线与answer中的m线顺序不匹配
this.pc.addTransceiver("audio", {direction: "recvonly"});
this.pc.addTransceiver("video", {direction: "recvonly"});
// 遍历getUserMedia()获取到的流数据,拿到其中的音频轨道和视频轨道,加入到RTCPeerConnection连接的音频轨道和视频轨道中
this.videoStream.getTracks().forEach((track)=>{
this.pc.addTrack(track);
});
// 创建本端offer
var offer = await this.pc.createOffer();
let that = this
// 设置本端
await that.pc.setLocalDescription(offer);
var data = {
"api": httpURL,
"streamurl":webRTCURL,
"sdp":offer.sdp
}
var pc2 = new RTCPeerConnection()
// pcBob.onicecandidate=(event)=>{
// if(event.candidate){
// pcAmy.addIceCandidate(event.candidate);
// }
// console.log("pcBob.onicecandidate",event.candidate)
// }
// SDP交换,请求SRS自带的信令服务器
this.httpApi(httpURL,data).then(async(data)=>{
console.log("answer",data);
// 设置远端描述,开始连接
await that.pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: data.sdp}));
// let pc2 = new RTCSessionDescription({type: 'answer', sdp: data.sdp})
this.$refs.button_one.disabled=true;
this.$refs.button_two.disabled=false;
this.$refs.button_three.disabled=false;
this.$refs.button_five.disabled=false;
// that.pc.onicecandidate=(event)=>{
// if(event.candidate){
// //that.
// pc2.addIceCandidate(event.candidate);
// }
// console.log("pcAmy.onicecandidate",event.candidate)
// }
}).catch((data)=>{
if(data.code===400){
console.log("SDP交换失败");
}
});
},
async play(){
let that = this
var httpURL = "http://192.168.5.111:1985/rtc/v1/play/";
var webRTCURL = "webRTC://192.168.5.111/live/10";
// 创建RTCPeerConnection连接对象
var pc = new RTCPeerConnection();
// 创建媒体流对象
var stream = new MediaStream();
// 获取播放流的容器video
var videoElement2 = document.querySelector("#video2");
// 监听流
pc.ontrack = (event)=>{
// 监听到的流加入MediaStream对象中让video播放
stream.addTrack(event.track);
console.log(event.track)
videoElement2.srcObject = stream;
}
// RTCPeerConnection方法addTransceiver()创建一个新的RTCRtpTransceiver,并将其添加到与RTCPeerConnection关联的收发器集中。
// 每个收发器代表一个双向流,RTCRtpSender和RTCRtpReceiver都与之相关联。
// 注意添加顺序为audio、video,后续RTCPeerConnection创建offer时SDP的m线顺序遵循此顺序创建,SRS自带的信令服务器响应的SDP中m线总是先audio后video。
// 若本端SDP和远端SDP中的m线顺序不一直,则设置远端描述时会异常,显示offer中的m线与answer中的m线顺序不匹配
pc.addTransceiver("audio", {direction: "recvonly"});
pc.addTransceiver("video", {direction: "recvonly"});
var offer =await pc.createOffer();
await pc.setLocalDescription(offer)
var data = {
"api": httpURL,
"streamurl":webRTCURL,
"sdp":offer.sdp
}
// SDP交换,请求SRS自带的信令服务器
this.httpApi(httpURL,data).then(async(data)=>{
console.log("answer",data);
// 设置远端描述,开始连接
await pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: data.sdp}));
that.$refs.button_five.disabled=true;
// pc.onicecandidate=(event)=>{
// if(event.candidate){
// //that.
// that.pc.addIceCandidate(event.candidate);
// }
// console.log("pcAmy.onicecandidate",event.candidate)
}).catch((data)=>{
if(data.code===400){
console.log("SDP交换失败");
}
});
},
// 关闭连接
close(){
if(this.pc!==null&&this.pc!==undefined){
this.pc.close();
this.pc = null;
this.$refs.button_one.disabled=false;
this.$refs.button_two.disabled=true;
this.$refs.button_three.disabled=true;
this.$refs.button_four.disabled=true;
this.$refs.button_five.disabled=true;
}
},
// 关闭音频
stopAudio(){
if(this.pc!==null&&this.pc!==undefined){
// RTCPeerConnection方法getSenders()返回RTCRtpSender对象的数组,
// 每个对象代表负责传输一个轨道的数据的RTP发送器。
// sender对象提供了检查和控制音轨数据的编码和传输的方法和属性。
this.pc.getSenders().forEach((sender)=>{
if(sender.track!==null&&sender.track.kind==="audio"){
// 拿到音频轨道
this.audioTrack = sender.track;
// 拿到音频轨道发送者对象RTCRtpSender
this.audioSender = sender;
// RTCRtpSender的replaceTrack()可以在无需重新媒体协商的情况下用另一个媒体轨道更换当前正在发送轨道
// 参数为空则将当前正在发送的轨道停止,比如关闭音频,再次开启时将音频轨道作为参数传入
this.audioSender.replaceTrack(null);
this.$refs.button_three.disabled=true;
this.$refs.button_four.disabled=false;
}
});
}
},
// 开启音频
startAudio(){
console.log(audioSender);
if(this.pc!==null&&this.pc!==undefined){
if(this.audioSender.track===null){
this.audioSender.replaceTrack(audioTrack);
this.$refs.button_three.disabled=false;
this.$refs.button_four.disabled=true;
}
}
},
httpApi(httpURL,data){
var promise = new Promise((resolve,reject)=>{
var xhr = new XMLHttpRequest();
xhr.open('POST', httpURL, true);
xhr.setRequestHeader('Content-type', 'application/json');
xhr.send(JSON.stringify(data));
xhr.onload = ()=>{
if (xhr.readyState !== xhr.DONE) reject(xhr);
if (xhr.status !== 200 && xhr.status !== 201) reject(xhr) ;
var data = JSON.parse(xhr.responseText);
if(data.code===0){
resolve(data);
}else{
reject(data)
}
}
});
return promise;
}
}
};
</script>
<style>
*{
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
#box{
width: 100%;
text-align: center;
}
video{
background-color: black;
width: 500px;
height: 400px;
object-fit: cover;
}
#btn{
width: 80%;
height: 100px;
display: flex;
margin:10px 10%;
}
button{
flex:1;
height: 100px;
background-color: aqua;
border-radius: 20px;
margin-left: 10px;
}
button:nth-child(1){
margin-left: 0;
}
</style>
说明:这里基于网上代码改编,具体来自那篇博客找不到了,有知道的可以评论区补充,谢谢!
Demo(3.0版本,自创WebRTC服务器)
参考 WebRTC入门概念 1 WebRTC入门概念 2 搭建STUN服务器 WebRTC 中经常用到的2个协议:STUN和TURN STUN 和 TURN 服务器我们使用 coturn开源项目 来搭建。 补充:ICE 跟 STUN 和 TURN 不一样,ICE不是一种协议,而是一个框架(Framework),它整合了STUN 和 TURN 。 在WebRTC中用来描述网络信息的术语叫 candidate:
- 媒体协商 sdp
- 网络协商 candidate
WebRTC学习
媒体协商(SDP交换)
同步音视频编码解码协议等的信息。 SDP是Session Description Protocol回话描述协议。
手写一个SDP信令服务器
通过websocket实现,逻辑其实很简单: 服务端:对收到的内容转发即可。
@ServerEndpoint("/webrtc/{roomId}")
@Component
@Slf4j
public class WebRTCServer {
/**
* 映射
* K: 房间号
* V:
*/
private static ConcurrentHashMap<String, CopyOnWriteArrayList<Session>> sessionsInRoom = new ConcurrentHashMap<>();
private Session session;
private String roomId;
@OnOpen
public void onOpen(Session session, @PathParam("roomId") String roomId) {
this.session = session;
this.roomId = roomId;
sessionsInRoom.putIfAbsent(roomId, new CopyOnWriteArrayList<>());
sessionsInRoom.get(roomId).add(session);
log.info("webrtc {} 连接.", session);
}
@OnClose
public void onClose() {
CopyOnWriteArrayList<Session> cur = sessionsInRoom.get(roomId);
if (cur != null) {
cur.remove(session);
if (cur.isEmpty()) {
sessionsInRoom.remove(roomId);
}
}
log.info("webrtc {} 关闭.", session);
}
@OnError
public void onError(Throwable throwable) {
log.error("webrtc 错误.", throwable);
}
@OnMessage
public void onMessage(String message) throws IOException {
CopyOnWriteArrayList<Session> sessions = sessionsInRoom.get(roomId);
log.info("来自{} 消息:{}", session, message);
if (sessions != null && !sessions.isEmpty()) {
for (Session s : sessions) {
if (s != session) {
s.getAsyncRemote().sendText(message);
}
}
}
}
}
客户端:Vue3+Vueuse(Websocket使用Vueuse封装的库实现)
- 逻辑基本思路=>
- offer:由播放端发出,告知推送端自己的SDP信息
- answer:推送端接受到播放端的offer请求后,向接受端发出answer请求,告知播放端自己的SDP信息
<template>
<div id="box">
<!-- 设置自动播放,否则不会显示视频流画面 -->
<video id="video" ref="video" autoplay></video>
<canvas style="display: contents;"></canvas>
<div id="btn">
<button ref="button_one" @click="publish">开始直播</button>
<button ref="button_five" @click="play">播放直播</button>
</div>
<video id="video2" ref="video2" autoplay></video>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useWebSocket } from '@vueuse/core'
const { status, data, send, open, close } = useWebSocket('ws://localhost:8080/webrtc/12345')
const RTCSessionDescription =
(window.webkitRTCSessionDescription || window.mozRTCSessionDescription || window.RTCSessionDescription || undefined);
const PeerConnection = (window.webkitRTCPeerConnection || window.mozRTCPeerConnection || window.RTCPeerConnection || undefined);
//ICE服务器
let pc = new PeerConnection(
{ iceServers: [{ urls: "stun:stun.services.mozilla.com" }], sdpSemantics: 'plan-b' }
)
pc.addTransceiver("audio", { direction: "recvonly" });
pc.addTransceiver("video", { direction: "recvonly" });
pc.onicecandidate = function (event) {
if (event.candidate) {
// webSocket发送数据
console.log("发送candidate", event.candidate)
send(JSON.stringify({
type: "candidate",
candidate: event.candidate
}));
}
};
//【WebSocket】
watch(data, () => {
let v = data.value
var vjson = JSON.parse(v);
console.log("指令", vjson)
switch (vjson.type) {
case "offer":
pc.setRemoteDescription(new RTCSessionDescription(vjson.offer));
pc.createAnswer(function (answer) {
pc.setLocalDescription(answer);
console.log("发送answer", answer)
send(JSON.stringify({
type: "answer",
answer: answer
}));
}, function (error) {
alert("Error when creating an answer");
});
break;
case "answer":
pc.setRemoteDescription(new RTCSessionDescription(vjson.answer));
break;
case "candidate":
pc.addIceCandidate(new RTCIceCandidate(vjson.candidate));
break;
case "leave":
console.log("关闭连接")
break;
default:
break;
}
})
async function publish() {
var constraints = {
audio: {
echoCancellation: true, // 回声消除
noiseSuppression: true, // 降噪
autoGainControl: true // 自动增益
},
video: {
frameRate: { min: 30 }, // 最小帧率
width: { min: 320, ideal: 320 }, // 宽度
height: { min: 180, ideal: 180 }, // 高度
aspectRadio: 16 / 9 // 宽高比
}
}
// 通过摄像头、麦克风获取音视频流
let videoStream = await navigator.mediaDevices.getUserMedia(constraints);
let videoElement = document.querySelector("#video")
videoElement.srcObject = videoStream;
videoElement.volume = 0;
// 遍历getUserMedia()获取到的流数据,拿到其中的音频轨道和视频轨道,加入到RTCPeerConnection连接的音频轨道和视频轨道中
videoStream.getTracks().forEach((track) => {
pc.addTrack(track);
});
let offer = await pc.createOffer();
await pc.setLocalDescription(offer)
console.log("发送offer", offer)
send(JSON.stringify(
{
type: "offer",
offer: offer
}
))
}
async function play() {
// 创建媒体流对象
var stream = new MediaStream();
// 获取播放流的容器video
var videoElement2 = document.querySelector("#video2");
// 监听流
pc.ontrack = (event) => {
// 监听到的流加入MediaStream对象中让video播放
stream.addTrack(event.track);
console.log(event.track)
videoElement2.srcObject = stream;
}
}
</script>
【待补充】WebRTC的实现方案之一:WebSocket+JSON/SDP
由于个人精力有限,这个内容待补充!!!
【杂谈】
- 本人喜欢学习一们技术的时候做总结,您现在看到的这篇文章可能是以前写好很久,之后也可能会对这篇文章继续修改!
- 由于文章一开始“初衷”是写给自己看的,所以内容可能有些地方过于简单或者含糊不清,请加以包容宽待,谢谢!
- 另外,本人写文章比较重视的是文章的目录结构,聪明的读者通过目录结构阅读我的文章也能比较容易清除内容的细节!
- 最后,创作不易,喜欢与不喜欢的读者点赞支持一下,谢谢!