Java实现实时视频转播
项目只是简易实现,后续关于性能的完善会速速完成
- 文章目录:
- 实现功能简述
- 第一步:后台启动摄像头,抓拍图像
- 第二步:后台获取的视频发送RTP包到前端
- 第三步:如何接收RTP包
- 关于之前几个版本的记录:
实现功能简述
最开始是想做一个在线的美妆功能,就像抖音上录制视频时增加特效一样。由于是后端程序员,我下意识的认为对图像/视频的处理都应该在后台完成,通过某种协议传给前端进行展示,我的项目也是基于此写成的。
主要用到的轮子:spring-boot,javaCV,Libjitsi,Webcam
第一步:后台启动摄像头,抓拍图像
while (isOpened) {
synchronized (lock) {
System.out.println("正在添加FFmpag");
BufferedImage image = webcam.getImage();
Frame frame = converter.getFrame(image);
recorder.record(frame);
if (!isFileStreamOn) {
isFileStreamOn = true;
}
}
Thread.yield();
}
此处用到Webcam包和JavaCV包
Webcam可以通过电脑摄像头获取BufferedImage图片(当然javaCV中也有类似的工具)
JavaCV包是用Java封装音视频处理的库,主要逻辑是将图片/视频/音频数据转化为Frame类,基于Frame对象进行操作。
上面代码中我将每张图片用转化器转化成Frame对象(很明显转化器类源码中的逻辑很值得研究),用FFmpegFrameRecorder将传入的图片转化成视频(文件/流)。
其中webcam,recorder和转化器在config中定义为bean:
@Bean
public Webcam getCam() {
Webcam webcam = Webcam.getDefault();
webcam.setViewSize(new Dimension(640, 480));
return webcam;
}
@Bean
public FFmpegFrameRecorder getRecorder(Webcam webcam){
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder("src/main/resources/static/output.avi",webcam.getViewSize().width,webcam.getViewSize().height);
recorder.setVideoCodecName("lib264");
recorder.setVideoCodec(avcodec.AV_CODEC_ID_MPEG4);
recorder.setFormat("avi");
recorder.setFrameRate(24);
return recorder;
}
@Bean
public Java2DFrameConverter getConverter(){
return new Java2DFrameConverter();
}
第二步:后台获取的视频发送RTP包到前端
while (isFileStreamOn) {
InputStream stream = new FileInputStream(file);
synchronized (lock) {
while (stream.available() > 0) {
byte[] bytes = new byte[1024];
int length = stream.read(bytes);
RawPacket rawPacket = new RawPacket(bytes, 0, length);
DatagramPacket udpPacket = new DatagramPacket(rawPacket.getBuffer(), rawPacket.getLength(), targetHost, targetPort);
udpSocket.send(udpPacket);
System.out.println("发送数据包 " + Arrays.toString(udpPacket.getData())) }
// file = new File(file.getAbsoluteFile());
try (FileWriter writer = new FileWriter(file)) {
writer.write(""); // 写入空字符串,清空文件内容
} catch (IOException e) {
e.printStackTrace();
}
stream = new FileInputStream(file);
}
}
注意点:
- 我将由FFmpegFrameRecorder写入文件的视频再次读取出来,并将此文件流分段,用基于webRTC的jitsi库进行打包发送。
jitsi是Java支持WebRTC(实时通信)的一个库,主要能够将数据以很小的时间间隔发送(据说视频可以做到每帧一个包,我没试也没必要)
webRTC中,RTP协议是基于UDP的,也就是说一个RTP会被拆分成多个UDP包。 - 问题1:为什么要先写入文件再读取文件?
其实FFmpegFrameRecorder用于图片转视频时,视频数据的写入方式其实可以是文件,也可以是输出流,假如可以获取一段时间的视频输出流,无论是切片转化还是加锁重置都很方便,但唯独是我用这个API有bug,底层的源码绕来绕去到达JNI,就完全不懂了。关于用文件缓存,我有另一个解决方法是:写入文件到一定大小,主动切换写另一个文件,将写好的文件存入队列中等待读取,但由于JavaCV的API我还不太熟练,如何给一个recorder对象切换输出流暂不明,此法仍需考虑。
- 问题2:写入文件和读取文件怎么做到异步?
出于实时性和用户使用效果的考虑,程序的设计一定是一个线程写,一个线程异步读取,以下是我的程序设计以及其中出现的问题和解决方式
- 用于获取视频帧的线程B先执行,启动摄像头并将视频帧加入recorder写入缓存(文件),写入一段时间后用于发送数据包的线程A开始执行,获取到缓存中的数据,分包,以UDP的形式发送到目标主机
- 为了避免两个线程对同一缓存读写冲突,他们的操作分别需要加锁。
- 怎么实现一个线程先于另一线程执行呢?sleep基于时间设置延迟但并不是根据逻辑;wait和notify是基于逻辑的但也有要求——由于两个方法是基于锁的,所以只能在有锁的同步代码块中执行,用起来没那么丝滑。
- 关于将输出流分包转储到package中,我考虑了两种方式:
- 持久读取一个流中的数据:用锁将此流锁住,然后就可以用固定长度的byte[]慢慢拆解。
- 立马读取此流的全部数据:不需要锁住流,只要将流中数据转入超大byte[]中,再慢慢拆分大byte[]
- 第一种在时间上性能消耗较大,对流的阻塞时间较长,第二种在内存上消耗较大,可能导致GC的STW较频繁。
- 为了防止中间缓存过大,需要一定时间将其进行重置,也就是对文件进行清空
有权利清空文件的一定是能确保文件读取完毕的线程,所以什么时候清空?我的想法是当读线程发现没有新数据可读时就进行清空,那有没有可能写线程一直在写导致不会有空隙没有新数据?不会,因为我的程序加锁的粒度比较大,当读线程全部读完后才会释放锁给写线程。也就是说读线程每次完整读完缓存就会清空。
完整代码:
import com.example.meitu2.utils.bfiOps;
import com.github.sarxos.webcam.Webcam;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.Java2DFrameConverter;
import org.jitsi.service.neomedia.RawPacket;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@RestController
public class video3Controller {
@Autowired
Webcam webcam;
@Autowired
List<Webcam> webcams;
@Autowired
com.example.meitu2.pojos.websocket websocket;
@Autowired
Java2DFrameConverter converter;
boolean isOpened = false;
boolean isFileStreamOn = false;
boolean needsLight = false;
int lightIndex = 0;
boolean needDuibidu = false;
int duibiduIndex = 0;
@GetMapping("RTPThread")
public void RTPThread() throws SocketException, UnknownHostException, FFmpegFrameRecorder.Exception, InterruptedException {
if (!webcam.isOpen()) {
webcam.open();
}
isOpened = true;
DatagramSocket udpSocket = new DatagramSocket();
InetAddress targetHost = InetAddress.getByName("localhost");
int targetPort = 2244;
// ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
File file = new File("Demo.mp4");
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(file.getAbsoluteFile(), webcam.getViewSize().width, webcam.getViewSize().height);
int a = 0;
recorder.setVideoCodecName("lib264");
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setFormat("mp4");
recorder.setFrameRate(24);
System.out.println("recorder: " + recorder);
recorder.start();
Object lock = new Object();
new Thread(() -> {
if(outputStream.size()==1024){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
while (isFileStreamOn) {
InputStream stream = new FileInputStream(file);
synchronized (lock) {
while (stream.available() > 0) {
byte[] bytes = new byte[1024];
int length = stream.read(bytes);
RawPacket rawPacket = new RawPacket(bytes, 0, length);
DatagramPacket udpPacket = new DatagramPacket(rawPacket.getBuffer(), rawPacket.getLength(), targetHost, targetPort);
udpSocket.send(udpPacket);
System.out.println("发送数据包 " + Arrays.toString(udpPacket.getData()));
}
System.out.println("一份file读完,更新file");
// file = new File(file.getAbsoluteFile());
try (FileWriter writer = new FileWriter(file)) {
writer.write(""); // 写入空字符串,清空文件内容
} catch (IOException e) {
e.printStackTrace();
}
stream = new FileInputStream(file);
}
}
System.out.println("一号外层while结束");
} catch (Exception e) {
throw new RuntimeException(e);
}
}).start();
while (isOpened) {
synchronized (lock) {
System.out.println("正在添加FFmpag");
BufferedImage image = webcam.getImage();
Frame frame = converter.getFrame(image);
recorder.record(frame);
if (!isFileStreamOn) {
isFileStreamOn = true;
}
}
Thread.yield();
}
recorder.stop();
recorder.release();
}
第三步:如何接收RTP包
由于前端代码不太熟练,我决定用Java写一个后台接收这些文件进行验证
public static void main(String[] args) throws IOException {
DatagramSocket ds = new DatagramSocket(2244);
byte[] bytes = new byte[1024];
int length = bytes.length;
Object lock = new Object(); //锁似乎没有必要
Queue<DatagramPacket> dps = new ArrayDeque<>();
new Thread() {
@Override
public void run() {
try {
FileOutputStream outputStream = new FileOutputStream("Demo.mp4");
while (!dps.isEmpty()) {
byte[] data = dps.poll().getData();
outputStream.write(data);
System.out.println(data);
if (data.length == 0) {
break;
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}.start();
while (true) {
DatagramPacket dp = new DatagramPacket(bytes, length);
System.out.println("接收到dp"+ Arrays.toString(dp.getData()));
dps.add(dp);
ds.receive(dp);
}
}
这里也是两个线程异步操作,一个用来接收,一个用来解读
值得一提的是:由于此时数据是分批来的,天然可以用队列有序操作。
而像之前数据是基于流进行传输,对一整个长时间的流数据的操作首先就需要合理的分片,这就要求涉及的线程通过加锁避免冲突。
关于之前几个版本的记录:
1. 后端之间存储mp4文件到静态资源,前端访问到即播放
后台代码:
@GetMapping("/setVideo")
public synchronized Result setVideo() throws FFmpegFrameRecorder.Exception { //默认获取三十秒的视频
//加锁是为了防止webcam被多个线程调用
if(!webcam.isOpen()){
webcam.open();
}
num++;
List<BufferedImage> list = new ArrayList<>();
long start = System.currentTimeMillis();
while (System.currentTimeMillis()-start<=10000){
BufferedImage bfi = webcam.getImage();
list.add(bfi);
}
System.out.println("鹿丸!,开存!");
webcam.close();
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder("src/main/resources/static/output.mp4",webcam.getViewSize().width,webcam.getViewSize().height);
recorder.setVideoCodecName("lib264");
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setFormat("mp4");
recorder.setFrameRate(24);
System.out.println("recorder: "+recorder);
recorder.start();
for (int i = 0; i < list.size(); i++) {
Frame frame = converter.getFrame(list.get(i));
recorder.record(frame);
}
recorder.stop();
recorder.release();
System.out.println("存完");
return Result.ok("down");
}
前端代码:
由于前端写的很乱,很多东西都挤在一个vue页面里,不太方面拆分
以下是核心实现代码:
const getVideo = function () {
axios.get("http://localhost:8080/output.mp4?t="+(new Date()).getTime(), { responseType: 'blob'}).then((result) => {
console.log("getVideo的result: ", result);
const blob = new Blob([result.data], { type: 'video/mp4' });
const videoURL = URL.createObjectURL(blob);
console.log("videoValue", videoURL);
video.value = videoURL;
})
}
const setVideo = function () {
console.log('发起setVideo,后台开始录制');
axios.get("http://localhost:8080/setVideo").then((result) => { //由于是axios异步请求,axios还没把videoURL填充
console.log("setVideo的result", result);
})
setTimeout(function () {
console.log("发起getVideo,获取后台录制好的avi");
getVideo();
setVideo();
}, 12000);
主要的思路是:
后端循环录制一定时间的视频(比如说是10s),一旦录制好就交给前端进行播放,类似在后端用mp4文件作为缓存一样
- 前端其实没办法知道后端什么时候视频更新了,于是要么轮询,要么长链接,要么前后端约定一个时间,我是基于最后一种办法,但很明显网络的传输和程序的不确定耗时必然导致时间的不准确——后端限制10s的视频常常录出12s…
- 还有一个我遇到的最大的问题是:浏览器的缓存问题:
明明后端的静态文件更新了,明明我发的AJAX里有时间戳,明明我在开发者工具中设置了禁止缓存…但无论是AJAX还是之间localhost:8080/output.mp4都是老视频
由于是直接访问后台静态资源,没法用MVC模板给response设置缓存,但可以加过滤器强行拦截;或者也有在前端增加增加配置来禁止缓存的,但也没用…
bytheway,关于视频编码格式以及于浏览器适配问题,我参考了下面的博客
https://blog.csdn.net/jiangxiayouyu/article/details/121863248?ops_request_misc=&request_id=&biz_id=102&utm_term=%E6%B5%8F%E8%A7%88%E5%99%A8%E8%AF%B7%E6%B1%82%E5%90%8E%E5%8F%B0mp4%E8%B5%84%E6%BA%90%E6%97%A0%E6%B3%95%E6%92%AD%E6%94%BE&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-0-121863248.142v93chatsearchT3_1&spm=1018.2226.3001.4187
还有一些额外的问题,比如,浏览器有时将访问到的视频是播放还是下载,取决于响应头Content-Disposition,可以setHeader强行更改为attachment表示为播放;通过工具将视频编码格式转化成支持浏览器的格式…
2. 用webSocket优化
webSocket的作用是在前后端之间建立平等互通的通道,主要是后台也可以给前端主动发起数据,用这个可以优化之前由于前后端规定时间访问而出现的错位问题
关于webSocket,它的设计还给前后端连接的建立提供了钩子,这个思想在Java中感觉不常见;在vue这种很强调生命周期的语法中很常见
如下:可以在建立连接,关闭连接,接收信息,发送消息时都写逻辑,就和前端能够更好地,平等的完成一致的功能了。
后端代码:
@GetMapping("openCam")
public void openCam() throws IOException {
if (!webcam.isOpen()) {
webcam.open();
}
isOpened = true;
DatagramSocket udpSocket = new DatagramSocket();
InetAddress targetHost = InetAddress.getByName("localhost");
int targetPort = 80;
while (isOpened) {
List<BufferedImage> list = new ArrayList<>();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 10000) {
BufferedImage bfi = webcam.getImage();
if (needsLight) {
bfi = bfiOps.light(bfi, lightIndex);
}
if (needDuibidu) {
bfi = bfiOps.duibidu(bfi, duibiduIndex);
}
list.add(bfi);
}
System.out.println("鹿丸!,开存!小节视频长度:" + (System.currentTimeMillis() - start));
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] byteArray = outputStream.toByteArray();
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(outputStream, webcam.getViewSize().width, webcam.getViewSize().height);
recorder.setVideoCodecName("lib264");
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setFormat("mp4");
recorder.setFrameRate(24);
System.out.println("recorder: " + recorder);
recorder.start();
for (int i = 0; i < list.size(); i++) {
Frame frame = converter.getFrame(list.get(i));
recorder.record(frame);
}
recorder.stop();
recorder.release();
System.out.println("后台保存完毕");
RawPacket rtpPacket = new RawPacket(byteArray, 0, byteArray.length);//构造中需要传入起始终结,可能表明不是让一遍传完
DatagramPacket udpPacket = new DatagramPacket(rtpPacket.getBuffer(), rtpPacket.getLength(), targetHost, targetPort);
udpSocket.send(udpPacket);
udpSocket.close();
websocket.sendOneMessage("0", "down");
}
webcam.close();
}
前端代码:
const openCam = function() {
socket = new WebSocket("ws://localhost:8080/websocket/"+userId);
socket.onopen = function(){
console.log("开启");
axios.get("http://localhost:8080/openCam").then((result)=>{
console.log(result);
})
}
socket.onmessage = function(msg){
console.log("收到消息",msg.data);
if(msg.data === "down"){
getVideo();
}
}
}