Java实现实时视频转播

Java实现实时视频转播

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

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

实现功能简述

最开始是想做一个在线的美妆功能,就像抖音上录制视频时增加特效一样。由于是后端程序员,我下意识的认为对图像/视频的处理都应该在后台完成,通过某种协议传给前端进行展示,我的项目也是基于此写成的。 主要用到的轮子:spring-boot,javaCV,Libjitsi,Webcam

第一步:后台启动摄像头,抓拍图像

java 复制代码
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:

java 复制代码
@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包到前端

java 复制代码
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较频繁。
    5. 为了防止中间缓存过大,需要一定时间将其进行重置,也就是对文件进行清空 有权利清空文件的一定是能确保文件读取完毕的线程,所以什么时候清空?我的想法是当读线程发现没有新数据可读时就进行清空,那有没有可能写线程一直在写导致不会有空隙没有新数据?不会,因为我的程序加锁的粒度比较大,当读线程全部读完后才会释放锁给写线程。也就是说读线程每次完整读完缓存就会清空。

完整代码:

java 复制代码
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写一个后台接收这些文件进行验证

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文件到静态资源,前端访问到即播放

后台代码:

java 复制代码
 @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页面里,不太方面拆分 以下是核心实现代码:

javascript 复制代码
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,关于视频编码格式以及于浏览器适配问题,我参考了下面的博客

blog.csdn.net/jiangxiayou...

还有一些额外的问题,比如,浏览器有时将访问到的视频是播放还是下载,取决于响应头Content-Disposition,可以setHeader强行更改为attachment表示为播放;通过工具将视频编码格式转化成支持浏览器的格式......

2. 用webSocket优化

webSocket的作用是在前后端之间建立平等互通的通道,主要是后台也可以给前端主动发起数据,用这个可以优化之前由于前后端规定时间访问而出现的错位问题

关于webSocket,它的设计还给前后端连接的建立提供了钩子,这个思想在Java中感觉不常见;在vue这种很强调生命周期的语法中很常见 如下:可以在建立连接,关闭连接,接收信息,发送消息时都写逻辑,就和前端能够更好地,平等的完成一致的功能了。

后端代码:

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

前端代码:

javascript 复制代码
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();
    }
  }
}
相关推荐
晨间晓21 小时前
图片里的高频和低频是什么?用华为云码道开发 OpenCV 傅里叶频谱分析工具
opencv·计算机视觉·华为云
晨间晓1 天前
图片里的阴影能不能减弱?用华为云码道开发 OpenCV 光照校正工具
人工智能·opencv·华为云
程序员阿明1 天前
docker搭建视频流服务go2rtc,webRTC
docker·容器·webrtc
晨间晓2 天前
图片里的细小噪点怎么平滑?用华为云码道开发 OpenCV 图像滤波对比工具
人工智能·opencv·华为云
偷吃米饭2 天前
图片特征怎么描述?用华为云码道开发 OpenCV ORB 特征提取工具
人工智能·opencv·华为云
晨间晓2 天前
图片里的关键点怎么找?用华为云码道开发 OpenCV FAST 特征点检测工具
人工智能·opencv·华为云
晨间晓2 天前
二值图里的噪点怎么去掉?用华为云码道开发 OpenCV 形态学操作工具
人工智能·opencv·华为云
晨间晓2 天前
图片里的角点在哪里?用华为云码道开发 OpenCV Harris 角点检测工具
人工智能·opencv·华为云
晨间晓2 天前
图片细节丰富不丰富?用华为云码道开发 OpenCV 纹理复杂度分析工具
人工智能·opencv·华为云
richard_yuu3 天前
OpenCV 实战第 15 篇:G-API 图计算,工业流水线的 CPU/GPU 混合执行
人工智能·opencv·计算机视觉