Java实现实时视频转播

项目只是简易实现,后续关于性能的完善会速速完成

  • 文章目录:
  1. 实现功能简述
  2. 第一步:后台启动摄像头,抓拍图像
  3. 第二步:后台获取的视频发送RTP包到前端
  4. 第三步:如何接收RTP包
  5. 关于之前几个版本的记录:

实现功能简述

最开始是想做一个在线的美妆功能,就像抖音上录制视频时增加特效一样。由于是后端程序员,我下意识的认为对图像/视频的处理都应该在后台完成,通过某种协议传给前端进行展示,我的项目也是基于此写成的。
主要用到的轮子: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:写入文件和读取文件怎么做到异步?
    出于实时性和用户使用效果的考虑,程序的设计一定是一个线程写,一个线程异步读取,以下是我的程序设计以及其中出现的问题和解决方式
  1. 用于获取视频帧的线程B先执行,启动摄像头并将视频帧加入recorder写入缓存(文件),写入一段时间后用于发送数据包的线程A开始执行,获取到缓存中的数据,分包,以UDP的形式发送到目标主机
  2. 为了避免两个线程对同一缓存读写冲突,他们的操作分别需要加锁。
  3. 怎么实现一个线程先于另一线程执行呢?sleep基于时间设置延迟但并不是根据逻辑;wait和notify是基于逻辑的但也有要求——由于两个方法是基于锁的,所以只能在有锁的同步代码块中执行,用起来没那么丝滑。
  4. 关于将输出流分包转储到package中,我考虑了两种方式:
  1. 持久读取一个流中的数据:用锁将此流锁住,然后就可以用固定长度的byte[]慢慢拆解。
  2. 立马读取此流的全部数据:不需要锁住流,只要将流中数据转入超大byte[]中,再慢慢拆分大byte[]
  3. 第一种在时间上性能消耗较大,对流的阻塞时间较长,第二种在内存上消耗较大,可能导致GC的STW较频繁。
  1. 为了防止中间缓存过大,需要一定时间将其进行重置,也就是对文件进行清空
    有权利清空文件的一定是能确保文件读取完毕的线程,所以什么时候清空?我的想法是当读线程发现没有新数据可读时就进行清空,那有没有可能写线程一直在写导致不会有空隙没有新数据?不会,因为我的程序加锁的粒度比较大,当读线程全部读完后才会释放锁给写线程。也就是说读线程每次完整读完缓存就会清空。

完整代码:

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这种很强调生命周期的语法中很常见

如下:可以在建立连接,关闭连接,接收信息,发送消息时都写逻辑,就和前端能够更好地,平等的完成一致的功能了。

java 解析摄像头设备视频流_java

后端代码:

@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();
    }
  }
}