使用Java CV 实现Http-flv、Hls流媒体服务器

7,794 阅读10分钟

前言


从摄像头获取的一般是RTSP协议的视频流,但浏览器不能直接播放RTSP流视频,需要流媒体服务进行转码操作。

浏览器支持的几种协议的简单对比:

协议http-flvrtmphlsdash
传输方式http流tcp流httphttp
视频封装格式flvflv tagTs文件Mp4 3gp webm
延时
数据分段连续流连续流切片文件切片文件
Html5播放可通过html5解封包播放(flv.js)不支持可通过html5解封包播放(hls.js)如果dash文件列表是mp4webm文件,可直接播放


rtmp协议需要flash支持,flash已经被淘汰了,所以直接不考虑这种方式。
这里选择实现 http-flv和hls格式的流媒体转换。

实现原理

使用Java CV来实现视频流转换。

Java CV

Java CV是Java在是视觉领域的一个工具包,详情了解,请参考官方文档或大佬的文章:JavaCV入门指南

JavaCV是计算机视觉领域的开发人员(OpenCV、FFmpeg、libdc1394、PGR FlyCapture、OpenKinect、li.lsense、CL PS3 Eye Driver、videoInput、ARToolKitPlus、flandmark、Leptonica和Tesseract)常用库的JavaCPP预置的包装器,并提供实用的程序类使它们的功能更容易在Java平台上使用,包括Android。

JavaCV还提供了硬件加速的全屏图像显示(CanvasFrame和GLCanvasFrame)、在多核(并行)上并行执行代码的简便方法、照相机和投影机的用户友好的几何和颜色校准(GeometricCalibrator,ProCamometricCalibrato)r,ProCamColorCalibrator),特征点的检测和匹配(ObjectFinder),一组用于实现投影仪-照相机系统的直接图像对齐的类(主要是GNImageAligner、ProjectiveTransformer、ProjectiveColorTransformer、ProCamTransformer和ReflectanceInitializer),一个blob分析包(BLUB),以及JavaCV类中的各种功能。其中一些类还具有OpenCL和OpenGL的对应类,它们的名称以CL结尾或以GL开始,即:JavaCVCL、GLCanvasFrame等。

FFmpeg

FFmpeg是一个视频格式转换工具。项目中的转码操作,主要通过这个工具来完成。
当然,是通过Java的方式调用。工具的相关库文件都封装在Java CV的依赖中。
FFmpeg介绍

FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频/视频编解码库libavcodec,为了保证高可移植性和编解码质量,libavcodec里很多code都是从头开发的。

代码实现

创建拉流器

   /**
     * 创建拉流器
     *
     * @return boolean
     */
    public MediaThread createGrabber() {
        log.info("{} | {} | {} | 创建拉流器……", sourceAddress, videoType, conversionType);
        // 拉流器
        grabber = new FFmpegFrameGrabber(sourceAddress);
        // 超时时间(5秒)
        grabber.setOption("stimoout", "5000000");
        grabber.setOption("threads", "1");
        grabber.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
        // 设置缓存大小,提高画质、减少卡顿花屏
        grabber.setOption("buffer_size", "1024000");
//        grabber.setOption("buffer_size", "100");
        // 如果为rtsp流,增加配置
        if ("rtsp".equals(sourceAddress.substring(0, 4))) {
            // 设置打开协议tcp / udp
            grabber.setOption("rtsp_transport", "tcp");
            //首选TCP进行RTP传输
            grabber.setOption("rtsp_flags", "prefer_tcp");
            //设置超时时间
            // -stimeout的单位是us 微秒(1秒=1*1000*1000微秒)。
            grabber.setOption("stimeout", "5*1000*1000");
        }
        try {
            grabber.start();
            grabberStatus = true;
        } catch (FrameGrabber.Exception e) {
            log.error("{} | 创建拉流器异常!", sourceAddress, e);
            setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建拉流器失败!");
        }
        return this;
    }

创建转码录制器

flv与hls的转码录制不同,分开处理

Flv

Flv方式转码时,将数据存在缓存中,然后推送刚给客户端。

    /**
     * flv转码
     */
    private void createATranscoderFlv() {

        if (StringUtils.isBlank(playAddress)) {
            // 生成观看地址
            this.playAddress = StreamMediaUtil.generatePlaybackAddress() + ".flv";
        }
        recorder = new FFmpegFrameRecorder(flvOutputStream, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
        recorder.setFormat(this.videoType);
        // 判断是否支持转复用
        if (this.supportReuse()) {
            try {
                log.info("{} | 启动转复用录制器……", sourceAddress);
                recorder.start(grabber.getFormatContext());
                recorderStatus = true;
                transferFlag = true;
            } catch (FrameRecorder.Exception e) {
                log.error("{} | 启动转复用录制器失败!", sourceAddress, e);
                setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "启动转复用录制器失败!");
            }
            return;
        }


        // 转码
        log.info("{} | 启动Flv转码录制器……", sourceAddress);
        recorder.setInterleaved(false);
        recorder.setVideoOption("tune", "zerolatency");
        recorder.setVideoOption("preset", "ultrafast");
        recorder.setVideoOption("crf", "26");
        recorder.setVideoOption("threads", "1");
        recorder.setFrameRate(25);// 设置帧率
        recorder.setGopSize(25);// 设置gop,与帧率相同,相当于间隔1秒chan's一个关键帧
//						recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
        recorder.setVideoCodecName("libx264");
//						recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
        recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
//						recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
        recorder.setAudioCodecName("aac");
        try {
            recorder.start();
            recorderStatus = true;
        } catch (FrameRecorder.Exception e) {
            log.error("{} | 创建转码录制器异常!", sourceAddress, e);
            setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建转码录制器失败!");
        }
    }

Hls

Hls方式转码时,需要将生成视频切片存在本地磁盘。

  
    /**
     * hls转码
     */
    private void createATranscoderHls() {

        // 创建文件夹
        // 生成观看地址
        if (StringUtils.isBlank(playAddress)) {
            playAddress = StreamMediaUtil.generatePlaybackAddress();
        }
        String path = StreamMediaConstant.HLS_DIR + "/" + playAddress + "/";
        thisVideoPath = path;
        // 检查文件夹是否存在,如果不存在,则创建
        File file = new File(path);
        if (!file.exists()) {
            file.mkdir();
        }
        path += StreamMediaConstant.VIDEO_FILE_NAME_HLS;
        recorder = new FFmpegFrameRecorder(path, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
        recorder.setFormat("hls");

        //关于hls_wrap的说明,hls_wrap表示重复覆盖之前ts切片,这是一个过时配置,ffmpeg官方推荐使用hls_list_size 和hls_flags delete_segments代替hls_wrap
        //设置单个ts切片的时间长度(以秒为单位)。默认值为2秒
        recorder.setOption("hls_time", "2");
        //不根据gop间隔进行切片,强制使用hls_time时间进行切割ts分片
//        recorder.setOption("hls_flags", "split_by_time");

        //设置播放列表条目的最大数量。如果设置为0,则列表文件将包含所有片段,默认值为5
        // 当切片的时间不受控制时,切片数量太小,就会有卡顿的现象
        recorder.setOption("hls_list_size", "4");
        //自动删除切片,如果切片数量大于hls_list_size的数量,则会开始自动删除之前的ts切片,只保留hls_list_size个数量的切片
        recorder.setOption("hls_flags", "delete_segments");
        //ts切片自动删除阈值,默认值为1,表示早于hls_list_size+1的切片将被删除
        recorder.setOption("hls_delete_threshold", "1");
        /*hls的切片类型:
         * 'mpegts':以MPEG-2传输流格式输出ts切片文件,可以与所有HLS版本兼容。
         * 'fmp4':以Fragmented MP4(简称:fmp4)格式输出切片文件,类似于MPEG-DASH,fmp4文件可用于HLS version 7和更高版本。
         */
        recorder.setOption("hls_segment_type", "mpegts");
        //指定ts切片生成名称规则,按数字序号生成切片,例如'file%03d.ts',就会生成file000.ts,file001.ts,file002.ts等切片文件
//        recorder.setOption("hls_segment_filename", path + "-%03d.ts");
        recorder.setOption("hls_segment_filename", path + "-%5d.ts");
        // 设置第一个切片的编号

        recorder.setOption("start_number", String.valueOf(tsCont));
        recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);

        // 判断是否支持转复用
        if (this.supportReuse()) {
            try {
                log.info("{} | 启动Hls转复用录制器……", sourceAddress);
                recorder.start(grabber.getFormatContext());
                recorderStatus = true;
                transferFlag = true;
            } catch (FrameRecorder.Exception e) {
                log.error("{} | 启动转复用录制器失败!", sourceAddress, e);
                setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "启动转复用录制器失败!");
            }
            return;
        }


        // 转码
        log.info("{} | 启动Hls转码录制器……", sourceAddress);
        //      设置零延迟
        recorder.setVideoOption("tune", "zerolatency");
        // 快速
        recorder.setVideoOption("preset", "ultrafast");
//        recorder.setVideoOption("crf", "26");
//        recorder.setVideoOption("threads", "1");
        recorder.setFrameRate(25);// 设置帧率
        recorder.setGopSize(25);// 设置gop,与帧率相同,相当于间隔1秒chan's一个关键帧
//						recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
        recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
        recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
        recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
        try {
            recorder.start();
            recorderStatus = true;
        } catch (FrameRecorder.Exception e) {
            log.error("{} | 创建转码录制器异常!", sourceAddress, e);
            setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建转码录制器失败!");
        }


    }

关于转复用与转码

转码:将视频解析成一帧帧数据,再按照指定格式封装成视频流。
转复用:获取的视频流不进行转码操作,直接再封装。
显然转复的形式性能消耗要小很多。但是这样用的前提,不进行转码操作的视频、音频格式也能被客户端解析。
参考:blog.csdn.net/eguid_1/art…
blog.csdn.net/eguid_1/art…

执行转码操作

Hls

    /**
     * 开启转流服务
     *
     */
    public void transform() {
        if (!grabberStatus || !recorderStatus) {
            return;
        }
        log.info("{} | 开启转流操作……", sourceAddress);
        try {
            grabber.flush();
        } catch (FrameGrabber.Exception e) {
            log.error("{} | 清空拉流器缓存失败!", sourceAddress, e);
        }
        String mapKey = sourceAddress + "-" + videoType;
        setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_SUCCESS, "");
        StreamMediaConstant.MEDIA_INFO_MAP.put(mapKey, this);
        // 服务启动完成
        starting = false;


        //时间戳计算
        int errorCount = 0;
        long currentTimeMillis;
        running = true;
        while (running) {
            currentTimeMillis = System.currentTimeMillis();

            // 出错次数过多时,重新建立连接或重启
            if (errorCount > 10 || reConnection) {
                if (transferFlag) {
                    // 重新建立连接对转复用不生效,所以进行重启服务
                    restartService = true;
                    break;
                }
                try {
                    log.info("{} | 重新建立链接……", sourceAddress);
                    grabber.restart();
                    grabber.flush();
                    // 重新建立建立成功,将错误次数清零
                    errorCount = 0;
                } catch (FrameGrabber.Exception e1) {
                    log.error("{} | 重新建立连接失败", sourceAddress, e1);
                    errorCount++;
                }
            }

            // 转流操作
            try {
                if (transferFlag) {
                    //转复用
                    AVPacket pkt = grabber.grabPacket();
                    if (null == pkt || pkt.isNull()) {
                        log.error("{} | pkt is null", sourceAddress);
                        errorCount++;
                        continue;
                    }
                    recorder.recordPacket(pkt);
                    av_packet_unref(pkt);
                } else {
                    //转码
                    Frame frame = grabber.grabFrame();
                    if (frame == null) {
                        log.error("{} | frame is null", sourceAddress);
                        errorCount++;
                        continue;
                    }
                    recorder.record(frame);
                }
            } catch (Exception e) {
                log.error("{} | 转流操作异常!", sourceAddress, e);
                errorCount++;
            }

            // 检查hls连接是否存活。每3分钟执行一次
            if (currentTimeMillis - httpCheckTime > (1000 * 60 * 3)) {
                httpCheckTime = currentTimeMillis;
                checkHlsHttp();
            }
        }

        // close包含stop和release方法。录制文件必须保证最后执行stop()方法
        try {
            recorder.close();
            grabber.close();
            if (null != tscCache) {
                tscCache.clear();
            }
        } catch (IOException e) {
            log.error("{} | 转流操作结束,关闭流异常!", sourceAddress, e);
        }

        // 删除本地文件
        File file = new File(thisVideoPath);
        if (file.exists()) {
            log.info("{} | 转流结束,删除本地文件。", sourceAddress);
            FileUtil.del(file);
        }

        // 需要重启
        if (restartService) {
            // 1分钟之内只重启一次
            log.info("{} | 重新启动……", sourceAddress);
            restartService = false;
            this.grabberStatus = false;
            this.recorderStatus = false;
            starting = true;
            reConnection = false;
            lastRestartTime = System.currentTimeMillis();
            this.createGrabber().createRecodeRecorder().transform();
        }

        log.info("{} | 转流操作结束", sourceAddress);
        StreamMediaConstant.MEDIA_INFO_MAP.remove(mapKey);
    }

Flv

 /**
     * 开启转流服务
     *
     * @return view
     */
    public void transform() {
        if (!grabberStatus || !recorderStatus) {
            return;
        }
        log.info("{} | 开启转流服务……", sourceAddress);
        try {
            grabber.flush();
        } catch (FrameGrabber.Exception e) {
            log.error("{} | 清空拉流器缓存失败", sourceAddress, e);
        }
        if (flvHeader == null) {
            flvHeader = flvOutputStream.toByteArray();
            flvOutputStream.reset();
        }
        String mapKey = sourceAddress + "-" + videoType;
        setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_SUCCESS, "");
        StreamMediaConstant.MEDIA_INFO_MAP.put(mapKey, this);
        // 服务启动完成
        starting = false;


        //时间戳计算
        int errorCount = 0;
        running = true;
        long videoTS;
        while (running) {
            // 出错次数过多时,重新建立连接或重启
            if (errorCount > 10) {
                if (transferFlag) {
                    // 转码重新建立连接对转复用不生效,所以进行重启服务
                    restartService = true;
                    break;
                }
                try {
                    log.info("{} | 重新建立链接……", sourceAddress);
                    grabber.restart();
                    grabber.flush();
                    // 重新建立建立成功,将错误次数清零
                    errorCount = 0;
                } catch (FrameGrabber.Exception e1) {
                    log.error("{} | 重新建立连接失败", sourceAddress, e1);
                    errorCount++;
                }
            }

            try {

                if (transferFlag) {
                    
                    //转复用
                    AVPacket pkt = grabber.grabPacket();
                    if (null == pkt || pkt.isNull()) {
                        log.error("{} | pkt is null", sourceAddress);
                        errorCount++;
                        continue;
                    }
                    recorder.recordPacket(pkt);
                    av_packet_unref(pkt);
                } else {
                    //转码
                    Frame frame = grabber.grabFrame();
                    if (frame == null) {
                        log.error("{} | frame is null", sourceAddress);
                        errorCount++;
                        continue;
                    }
                    recorder.record(frame);
                }
            } catch (Exception e) {
                log.error("{} | 转流操作异常", sourceAddress, e);
                errorCount++;
            }


            if (flvOutputStream.size() > 0) {
                theLatestData = flvOutputStream.toByteArray();
                // 发送视频到前端
                sendFlvFrameData(theLatestData);
                flvOutputStream.reset();
            }

        }

        // close包含stop和release方法。录制文件必须保证最后执行stop()方法
        try {
            recorder.close();
            grabber.close();
            flvOutputStream.close();
        } catch (IOException e) {
            log.error("{} | 转流操作结束,关闭流异常。", sourceAddress, e);
        }

        // 进行重启
        if (restartService) {
            log.info("{} | 重新启动……", sourceAddress);
            flvOutputStream = new ByteArrayOutputStream();
            restartService = false;
            this.grabberStatus = false;
            this.recorderStatus = false;
            starting = true;
            firstPushAfterReStart = true;
            this.createGrabber().createRecodeRecorder().transform();
        }
        log.info("{} | 结束转流服务。", sourceAddress);
        StreamMediaConstant.MEDIA_INFO_MAP.remove(mapKey);
    }

监听Hls请求

Hls协议的视频流,其本质是将视频切成一个个小的视频片段,然后客户端通过HTTP请求来获取这些视频片段,实现播放。
所以监听Hls协议的请求,只需要实现根据请求参数,从磁盘读取文件的功能即可。

    public void getHlsFile(HttpServletRequest request, HttpServletResponse response, String playName) {

        String ipAddress = StreamMediaUtil.getIpAddress(request);
        response.addHeader("Content-Disposition", "attachment;filename=play.m3u8");
        response.setHeader("Access-Control-Allow-Origin", "*");
        if (playName.toLowerCase().endsWith("m3u8")) {
            response.setContentType("application/x-mpegURL");
        } else {
            response.setContentType("video/MP2T");
        }
        /*
        .M3U8 application/x-mpegURL or vnd.apple.mpegURL
        .ts video/MP2T
         */
        if (null == httpMap.get(ipAddress)) {
            log.info("{} | {} 新增连接!当前连接数:{}", sourceAddress, ipAddress, httHlsClientMap.size() + 1);
        }
        httHlsClientMap.put(ipAddress, System.currentTimeMillis());
        httpMap.put(ipAddress, System.currentTimeMillis());

        String pathFile = thisVideoPath + playName;
        byte[] fileByte = getFileByte(pathFile);
        if (null == fileByte) {
            return;
        }
        ServletOutputStream outputStream = null;
        try {
            outputStream = response.getOutputStream();
            outputStream.write(fileByte);
            outputStream.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != outputStream) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }


    /**
     * 获取文件数据
     *
     * @param pathFile 文件地址
     * @return
     */
    private byte[] getFileByte(String pathFile) {
        // 服务正在重启
        if (starting || restartService) {
            while (true) {
                if (starting || restartService) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    break;
                }
            }
        }

        Object bites = null;
        if (pathFile.endsWith(".ts")) {
            // 获取最新的ts下标
            tsCont = Integer.parseInt(pathFile.substring(pathFile.lastIndexOf("-") + 1, pathFile.lastIndexOf(".")));
            if (this.tscCache.containsKey(pathFile)) {
                bites = tscCache.get(pathFile);
            }
        }
        if (null != bites) {
            return (byte[]) bites;
        }
        File file = new File(pathFile);
        if (!file.exists()) {
            return null;
        }

        FileReader fileReader = FileReader.create(file);
        byte[] bytes = fileReader.readBytes();
        // 这里可以缓存一部分数据,当多个客户端同时播放时,减少磁盘文件读取速度。当缓存满后,会清除先放进去的对象
        tscCache.put(pathFile, bytes, (1000 * 6));
        return bytes;
    }

监听Http-Flv请求

与Hls协议不同,Flv协议是以建立长连接的方式,来向客户端传送数据。
长连接可以通过websocket,也可以是其他方式。
这里使用了另外一种,直接以Http建立长连接。其本质是一个请求时间无限长的普通Http请求。

 /**
     * 新增http客戶端
     */
    public void addFlvHttpClient(HttpServletRequest request, HttpServletResponse response) {
        if (running) {
            response.addHeader("Content-Disposition", "attachment;filename=\"" + playAddress + "\"");
            response.setContentType("video/x-flv");
            response.setHeader("Connection", "keep-alive");
            response.setHeader("accept_ranges", "bytes");
            response.setHeader("pragma", "no-cache");
            response.setHeader("cache_control", "no-cache");
            response.setHeader("transfer_encoding", "CHUNKED");
            response.setHeader("SERVER", "hmsm");
            String ipAddress = StreamMediaUtil.getIpAddress(request);


            response.setStatus(200);
            ServletOutputStream outputStream = null;
            try {
                outputStream = response.getOutputStream();
            } catch (IOException e) {
                log.error("{} | {} outputStream获取失败!", sourceAddress, ipAddress, e);
            }
            if (null == outputStream) {
                return;
            }
            try {
                outputStream.write(flvHeader, 0, flvHeader.length);
                outputStream.flush();
            } catch (IOException e) {
                log.error("{} | {} 写入头部数据失败!", sourceAddress, ipAddress, e);
            }

            httpFlvClientMap.put(ipAddress, outputStream);
            httpMap.put(ipAddress, System.currentTimeMillis());
            log.info("{} | {} 新增连接!当前连接数:{}", sourceAddress, ipAddress, httpMap.size());
            while (true) {
                // 线程不释放,保持连接。
                if (null == httpFlvClientMap.get(ipAddress)) {
                    // 当链接被移除后,则结束方法,关闭线程。
                    log.error("{} | {} 结束线程!", sourceAddress, ipAddress);
                    break;
                }
                try {
                    // 线程睡眠,只要线程不结束,就不会断掉http连接
                    // 不知道是否有更好的方式
                    Thread.sleep(1000 * 60 * 1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }


 private void sendFlvFrameData(byte[] data) {
        Iterator<Map.Entry<String, ServletOutputStream>> iterator = httpFlvClientMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String, ServletOutputStream> next = iterator.next();
            ServletOutputStream outputStream = null;
            try {
                outputStream = next.getValue();
                outputStream.write(data, 0, data.length);
                outputStream.flush();
            } catch (java.lang.Exception e) {
                try {
                    if (null != outputStream) {
                        outputStream.close();
                    }
                } catch (IOException ioException) {
                    ioException.printStackTrace();
                }
                httpMap.remove(next.getKey());
                iterator.remove();
                log.error("{} | {} | 推流失败,关闭连接。剩余连接:{}", sourceAddress, next.getKey(), httpMap.size(), e);
            }
        }
        firstPushAfterReStart = false;
        hasClient();
    }

注意事项

  1. 转复用的方式长期执行后,会出现 null == pkt 的情况,目前怀疑是和buffer_size 的设置或视频源有关,没有找到解决方案,这里通过重启服务来应对。
  2. Hls的方式转流时,切片数量和切片时间都不应该太小,否则会出现延迟和卡顿的情况。当然这个和服务器性能和网络情况有关。
  3. 测试发现,Flv的形式,延迟在3~5秒左右,Hls的形式,延迟最低在6秒左右。Flv的性能也优于Hls。

源码地址

gitee.com/yefengr/ast…

参考

简述HLS,HTTP,RTSP,RTMP协议的区别
JavaCV开发实战教程(JavaCV教程)
代码大量参考: EasyMedia