• 学成在线(第13天)在线学习 HLS


     在线学习需求分析

    学成在线作为在线教育网站,提供多种学习形式,包括:录播、直播、图文、社群等,学生登录进入学习中心即可
    在线学习,本章节将开发录播课程的在线学习功能,需求如下:
    1、学生可以在windows浏览器上在线观看视频。
    2、播放器具有快进、快退、暂停等基本功能。
    3、学生可以方便切换章节进行学习。

     流媒体

    流媒体就是将视频文件分成许多小块儿,将这些小块儿作为数据包通过网络发送出去,实现一边传输视
    频 数据 包一边观看视频。

    流式传输
    在网络上传输音、视频信息有两个方式:下载和流式传输。
    下载:就是把音、视频文件完全下载到本机后开始播放,它的特点是必须等到视频文件下载完成方可播放,
    播放等待时间较长,无法去播放还未下载的部分视频。
    流式传输:就是客户端通过链接视频服务器实时传输音、视频信息,实现“边下载边播放”。
    流式传输包括如下两种方式:
    1) 顺序流式传输
    即顺序下载音、视频文件,可以实现边下载边播放,不过,用户只能观看已下载的视频内容,无法快进到未
    下载的视频部分,顺序流式传输可以使用Http服务器来实现,比如Nginx、Apache等。
    2)实时流式传输
    实时流式传输可以解决顺序流式传输无法快进的问题,它与Http流式传输不同,它必须使用流媒体服务器并
    且使用流媒体协议来传输视频,它比Http流式传输复杂。常见的实时流式传输协议有RTSP、RTMP、RSVP
    等。

    流媒体系统的概要结构
    通过流媒体系统的概要结构学习流媒体系统的基本业务流程。

    1、将原始的视频文件通过编码器转换为适合网络传输的流格式,编码后的视频直接输送给媒体服务器。
    原始的视频文件通常是事先录制好的视频,比如通过摄像机、摄像头等录像、录音设备采集到的音视频文
    件,体积较大,要想在网络上传输需要经过压缩处理,即通过编码器进行编码 。
    2、媒体服务获取到编码好的视频文件,对外提供流媒体数据传输接口,接口协议包括 :HTTP、RTSP、
    RTMP等 。
    3、播放器通过流媒体协议与媒体服务器通信,获取视频数据,播放视频。

    HLS是什么?

    HLS的工作方式是:将视频拆分成若干ts格式的小文件,通过m3u8格式的索引文件对这些ts小文件建立索引。一般
    10秒一个ts文件,播放器连接m3u8文件播放,当快进时通过m3u8即可找到对应的索引文件,并去下载对应的ts文
    件,从而实现快进、快退以近实时 的方式播放视频。
    IOS、Android设备、及各大浏览器都支持HLS协议。

    采用 HLS方案即可实现边下载边播放,并可不用使用rtmp等流媒体协议,不用构建专用的媒体服务器,节省成本。
    本项目点播方案确定为方案3。

    FFmpeg  的基本使用

    我们将视频录制完成后,使用视频编码软件对视频进行编码,本项目 使用FFmpeg对视频进行编码 。

    下载 :ffmpeg-20180227-fa0c9d6-win64-static.zip,并解压,本教程将ffmpeg解压到了
    F:devenvedusoftffmpeg-20180227-fa0c9d6-win64-staticffmpeg-20180227-fa0c9d6-win64-static下。
    将F:devenvedusoftffmpeg-20180227-fa0c9d6-win64-staticffmpeg-20180227-fa0c9d6-win64-staticin目
    录配置在path环境变量中。
    检测是否安装成功:

      生成m3u8/ts文件

    使用ffmpeg生成 m3u8的步骤如下:
    第一步:先将avi视频转成mp4

    ffmpeg.exe -i  lucene.avi -c:v libx264 -s 1280x720 -pix_fmt yuv420p -b:a 63k -b:v 753k -r 18 lucene.mp4

    第二步:将mp4生成m3u8

    ffmpeg -i  lucene.mp4   -hls_time 10 -hls_list_size 0  -hls_segment_filename ./hls/lucene_%05d.ts ./hls/lucene.m3u8

    -hls_time 设置每片的长度,单位为秒
    -hls_list_size n: 保存的分片的数量,设置为0表示保存所有分片
    -hls_segment_filename :段文件的名称,%05d表示5位数字
    生成的效果是:将lucene.mp4视频文件每10秒生成一个ts文件,最后生成一个m3u8文件,m3u8文件是ts的索引
    文件。

     播放器

    视频编码后要使用播放器对其进行解码、播放视频内容。在web应用中常用的播放器有flash播放器、H5播放器或
    浏览器插件播放器,其中以flash和H5播放器最常见。
    flash播放器:缺点是需要在客户机安装Adobe Flash Player播放器,优点是flash播放器已经很成熟了,并且浏览
    器对flash支持也很好。
    H5播放器:基于h5自带video标签进行构建,优点是大部分浏览器支持H5,不用再安装第三方的flash播放器,并
    且随着前端技术的发展,h5技术会越来越成熟。
    本项目采用H5播放器,使用Video.js开源播放器。
    Video.js是一款基于HTML5世界的网络视频播放器。它支持HTML5和Flash视频,它支持在台式机和移动设备上播
    放视频。这个项目于2010年中开始,目前已在40万网站使用。

    Nginx媒体服务器

    HLS协议基于Http协议,本项目使用Nginx作为视频服务器。下图是Nginx媒体服务器的配置流程图:

    1.用户打开www.xuecheng.com上边的 video.html网页 

    2.video.xuecheng.com进行负载均衡处理,将视频请求转发到媒体服务器

    根据上边的流程,我们在媒体服务器上安装Nginx,并配置如下:

    #学成网媒体服务
    server {
    listen       90;    
    server_name  localhost;    
    #视频目录    
    location /video/ {    
    alias   F:/develop/video/;        
    }    
    }

    媒体服务器代理

    媒体服务器不止一台,通过代理实现负载均衡功能,使用Nginx作为媒体服务器的代理,此代理服务器作为
    video.xuecheng.com域名服务器。
    配置video.xuecheng.com虚拟主机:
    注意:开发中代理服务器和媒体服务器在同一台服务器,使用同一个Nginx。

    学成网媒体服务代理
    map $http_origin $origin_list{
        default http://www.xuecheng.com;
        "~http://www.xuecheng.com" http://www.xuecheng.com;
        "~http://ucenter.xuecheng.com" http://ucenter.xuecheng.com;
    }
    #学成网媒体服务代理
    server {
    listen       80;    
    server_name video.xuecheng.com;    
       
    location /video {      
    proxy_pass http://video_server_pool;          
    add_header Access‐Control‐Allow‐Origin $origin_list;        
    #add_header Access‐Control‐Allow‐Origin *;        
    add_header Access‐Control‐Allow‐Credentials true;          
    add_header Access‐Control‐Allow‐Methods GET;        
    }     
       
    }

    video_server_pool的配置如下:

    #媒体服务
        upstream video_server_pool{
         server 127.0.0.1:90 weight=10;    
        } 

     测试video.js

    1、编写测试页面video.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta http‐equiv="content‐type" content="text/html; charset=utf‐8" />
        <title>视频播放</title>
        <link href="/plugins/videojs/video‐js.css" rel="stylesheet">
    </head>
    <body>
    <video id=example‐video width=800 height=600 class="video‐js vjs‐default‐skin vjs‐big‐play‐
    centered" controls poster="http://127.0.0.1:90/video/add.jpg">
        <source
                src="http://video.xuecheng.com/video/hls/lucene.m3u8"
                type="application/x‐mpegURL">
    </video>
    <input type="button" onClick="switchvideo()" value="switch"/>
    <script src="/plugins/videojs/video.js"></script>
    <script src="/plugins/videojs/videojs‐contrib‐hls.js"></script>
    <script>
        var player = videojs('example‐video');
        //player.play();
    //切换视频    
        function switchvideo(){
            player.src({
                src: 'http://video.xuecheng.com/video/hls/lucene.m3u8',
                type: 'application/x‐mpegURL',
                withCredentials: true
    });
            player.play();
        }
    </script>
    </body>
    </html>
    View Code

    2、测试
    配置hosts文件,本教程开发环境使用Window10,修改C:WindowsSystem32driversetchosts文件

    127.0.0.1 video.xuecheng.com

     搭建学习中心前端

    学成网学习中心提供学生在线学习的各各模块,上一章节测试的点播学习功能也属于学习中心的一部分,本章节将
    实现学习中心点播学习的前端部分。之所以先实现前端部分,主要是因为要将video.js+vue.js集成,一部分精力还
    是要放在技术研究。

    先看一下界面原型,如下图,最终的目标是在此页面使用video.js播放视频。

     配置域名

    学习中心的二级域名为ucenter.xuecheng.com,我们在nginx中配置ucenter虚拟主机。

    #学成网用户中心
    server {
    listen       80;    
    server_name ucenter.xuecheng.com;    
       
    #个人中心    
    location / {      
    proxy_pass http://ucenter_server_pool;          
    }     
    }
    #前端ucenter
    upstream ucenter_server_pool{
      #server 127.0.0.1:7081 weight=10;
      server 127.0.0.1:13000 weight=10;
    }

    调试视频播放页面

    使用vue-video-player组件将video.js集成到vue.js中,本项目使用vue-video-player实现video.js播放。
    组件地址:https://github.com/surmon-china/vue-video-player
    上面的 xc-ui-pc-learning工程已经添加vue-video-player组件,我们在vue页面直接使用即可。
    前边我们已经测试通过 video.js,下面我们直接在vue页面中使用vue-video-player完成视频播放。
    导入learning_video.vue页面到course 模块下。
    配置路由:

    import learning_video from '@/module/course/page/learning_video.vue';
      {
        path: '/learning/:courseId/:chapter',
        component: learning_video,
        name: '录播视频学习',
        hidden: false,
        iconCls: 'el‐icon‐document'
      }

    预览效果:
    请求:http://ucenter.xuecheng.com/#/learning/1/2
    第一个参数: courseId,课程id,这里是测试页面效果随便输入一个ID即可,这里输入1
    第二个参数:chapter,课程计划id,这里是测试页面效果随便输入一个ID即可,这里输入2

     媒资管理

    每个教学机构都可以在媒资系统管理自己的教学资源,包括:视频、教案等文件。
    目前媒资管理的主要管理对象是课程录播视频,包括:媒资文件的查询、视频上传、视频删除、视频处理等。
    媒资查询:教学机构查询自己所拥有的媒体文件。
    视频上传:将用户线下录制的教学视频上传到媒资系统。
    视频处理:视频上传成功,系统自动对视频进行编码处理。
    视频删除 :如果该视频已不再使用,可以从媒资系统删除。

    下边是媒资系统与其它系统的交互情况:

    1、上传媒资文件
    前端/客户端请求媒资系统上传文件。
    文件上传成功将文件存储到媒资服务器,将文件信息存储到数据库。
    2、使用媒资
    课程管理请求媒资系统查询媒资信息,将课程计划与媒资信息对应、存储。
    3、视频播放
    用户进入学习中心请求学习服务学习在线播放视频。
    学习服务校验用户资格通过后请求媒资系统获取视频地址。

    业务流程

    服务端需要实现如下功能:
    1、上传前检查上传环境
    检查文件是否上传,已上传则直接返回。
    检查文件上传路径是否存在,不存在则创建。
    2、分块检查
    检查分块文件是否上传,已上传则返回true。
    未上传则检查上传路径是否存在,不存在则创建。
    3、分块上传
    将分块文件上传到指定的路径。
    4、合并分块
    将所有分块文件合并为一个文件。
    在数据库记录文件信息。

    上传注册

    1、配置
    application.yml配置上传文件的路径:

    xc‐service‐manage‐media:
      upload‐location: F:/develop/video/

    2、定义Dao
    媒资文件管理Dao

    public interface MediaFileRepository extends MongoRepository<MediaFile,String> {
    }

    3、Service
    功能:
    1)检查上传文件是否存在
    2)创建文件目录

    @Service
    public class MediaUploadService {
     private final static Logger LOGGER = LoggerFactory.getLogger(MediaUploadController.class);
        @Autowired
        MediaFileRepository mediaFileRepository;
        //上传文件根目录
        @Value("${xc‐service‐manage‐media.upload‐location}")
        String uploadPath;
        /**
         * 根据文件md5得到文件路径
         * 规则:
         * 一级目录:md5的第一个字符
         * 二级目录:md5的第二个字符
         * 三级目录:md5
         * 文件名:md5+文件扩展名
         * @param fileMd5 文件md5值
         * @param fileExt 文件扩展名
         * @return 文件路径
         */
        private String getFilePath(String fileMd5,String fileExt){
            String filePath = uploadPath+fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) +
    "/" + fileMd5 + "/" + fileMd5 + "." + fileExt;
            return filePath;
        }
        //得到文件目录相对路径,路径中去掉根目录
        private String getFileFolderRelativePath(String fileMd5,String fileExt){
            String filePath = fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" +
    fileMd5 + "/";
            return filePath;
        }
        //得到文件所在目录
        private String getFileFolderPath(String fileMd5){
            String fileFolderPath = uploadPath+ fileMd5.substring(0, 1) + "/" + fileMd5.substring(1,
    2) + "/" + fileMd5 + "/" ;
            return fileFolderPath;
        }
        //创建文件目录
        private boolean createFileFold(String fileMd5){
            //创建上传文件目录
            String fileFolderPath = getFileFolderPath(fileMd5);
            File fileFolder new File(fileFolderPath);
            if (!fileFolder.exists()) {
                //创建文件夹
                boolean mkdirs = fileFolder.mkdirs();
                return mkdirs;
            }
            return true;
        }
     //文件上传注册
        public ResponseResult register(String fileMd5, String fileName, String fileSize, String
    mimetype, String fileExt) {
            //检查文件是否上传
            //1、得到文件的路径
            String filePath = getFilePath(fileMd5, fileExt);
            File file new File(filePath);
            //2、查询数据库文件是否存在
            Optional<MediaFile> optional = mediaFileRepository.findById(fileMd5);
            //文件存在直接返回
            if(file.exists() && optional.isPresent()){
                ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_EXIST);
            }
            boolean fileFold = createFileFold(fileMd5);
            if(!fileFold){
                //上传文件目录创建失败
                ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_CREATEFOLDER_FAIL);
            }
            return new ResponseResult(CommonCode.SUCCESS);
        }
      
       
    }
    View Code

    分块检查

    在Service 中定义分块检查方法:

    //得到块文件所在目录
    private String getChunkFileFolderPath(String fileMd5){
    String fileChunkFolderPath = getFileFolderPath(fileMd5) +"/" + "chunks" + "/";    
    return fileChunkFolderPath;    
    }
    //检查块文件
    public CheckChunkResult checkchunk(String fileMd5, String chunk, String chunkSize) {
        //得到块文件所在路径
        String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
        //块文件的文件名称以1,2,3..序号命名,没有扩展名
        File chunkFile = new File(chunkfileFolderPath+chunk);
        if(chunkFile.exists()){
            return new CheckChunkResult(MediaCode.CHUNK_FILE_EXIST_CHECK,true);
        }else{
            return new CheckChunkResult(MediaCode.CHUNK_FILE_EXIST_CHECK,false);
        }
    }

    上传分块

    在Service 中定义分块上传分块方法:

    //块文件上传
    public ResponseResult uploadchunk(MultipartFile file, String fileMd5, String chunk) {
        if(file == null){
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_ISNULL);
        }
        //创建块文件目录
        boolean fileFold = createChunkFileFolder(fileMd5);
        //块文件
        File chunkfile = new File(getChunkFileFolderPath(fileMd5) + chunk);
        //上传的块文件
        InputStream inputStream= null;
        FileOutputStream outputStream null;
        try {
            inputStream = file.getInputStream();
            outputStream new FileOutputStream(chunkfile);
            IOUtils.copy(inputStream,outputStream);
        } catch (Exception e) {
            e.printStackTrace();
            LOGGER.error("upload chunk file fail:{}",e.getMessage());
            ExceptionCast.cast(MediaCode.CHUNK_FILE_UPLOAD_FAIL);
        }finally {
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }
        //创建块文件目录
        private boolean createChunkFileFolder(String fileMd5){
            //创建上传文件目录
            String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
            File chunkFileFolder new File(chunkFileFolderPath);
            if (!chunkFileFolder.exists()) {
                //创建文件夹
                boolean mkdirs = chunkFileFolder.mkdirs();
                return mkdirs;
            }
            return true;
        }
    View Code

    合并分块

    在Service 中定义分块合并分块方法,功能如下:
    1)将块文件合并

    2 )校验文件md5是否正确
    3)向Mongodb写入文件信息

    public ResponseResult mergechunks(String fileMd5, String fileName, Long fileSize, String
    mimetype, String fileExt) {
        //获取块文件的路径
        String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
        File chunkfileFolder new File(chunkfileFolderPath);
        if(!chunkfileFolder.exists()){
            chunkfileFolder.mkdirs();
        }
        //合并文件路径
        File mergeFile = new File(getFilePath(fileMd5,fileExt));
        //创建合并文件
        //合并文件存在先删除再创建
        if(mergeFile.exists()){
            mergeFile.delete();
        }
        boolean newFile = false;
        try {
            newFile = mergeFile.createNewFile();
        } catch (IOException e) {
            e.printStackTrace();
            LOGGER.error("mergechunks..create mergeFile fail:{}",e.getMessage());
        }
        if(!newFile){
            ExceptionCast.cast(MediaCode.MERGE_FILE_CREATEFAIL);
        }
        //获取块文件,此列表是已经排好序的列表
        List<File> chunkFiles = getChunkFiles(chunkfileFolder);
        //合并文件
        mergeFile = mergeFile(mergeFile, chunkFiles);
        if(mergeFile == null){
            ExceptionCast.cast(MediaCode.MERGE_FILE_FAIL);
        }
        //校验文件
        boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
        if(!checkResult){
            ExceptionCast.cast(MediaCode.MERGE_FILE_CHECKFAIL);
        }
        //将文件信息保存到数据库
        MediaFile mediaFile = new MediaFile();
        mediaFile.setFileId(fileMd5);
        mediaFile.setFileName(fileMd5+"."+fileExt);
        mediaFile.setFileOriginalName(fileName);
        //文件路径保存相对路径
        mediaFile.setFilePath(getFileFolderRelativePath(fileMd5,fileExt));
        mediaFile.setFileSize(fileSize);
        mediaFile.setUploadTime(new Date());
        mediaFile.setMimeType(mimetype);
        mediaFile.setFileType(fileExt);
    //状态为上传成功
        mediaFile.setFileStatus("301002");
        MediaFile save = mediaFileDao.save(mediaFile);
        return new ResponseResult(CommonCode.SUCCESS);
    }
     //校验文件的md5值
        private boolean checkFileMd5(File mergeFile,String md5){
            if(mergeFile == null || StringUtils.isEmpty(md5)){
                return false;
            }
            //进行md5校验
            FileInputStream mergeFileInputstream = null;
            try {
                mergeFileInputstream new FileInputStream(mergeFile);
                //得到文件的md5
                String  mergeFileMd5 = DigestUtils.md5Hex(mergeFileInputstream);
                //比较md5
                if(md5.equalsIgnoreCase(mergeFileMd5)){
                   return true;
                }
            } catch (Exception e) {
                e.printStackTrace();
                LOGGER.error("checkFileMd5 error,file is:{},md5 is:
    {}",mergeFile.getAbsoluteFile(),md5);
            }finally{
                try {
                    mergeFileInputstream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return false;
        }
    //获取所有块文件
        private List<File> getChunkFiles(File chunkfileFolder){
            //获取路径下的所有块文件
            File[] chunkFiles = chunkfileFolder.listFiles();
            //将文件数组转成list,并排序
            List<File> chunkFileList = new ArrayList<File>();
            chunkFileList.addAll(Arrays.asList(chunkFiles));
            //排序
            Collections.sort(chunkFileList, new Comparator<File>() {
                @Override
                public int compare(File o1, File o2) {
                    if(Integer.parseInt(o1.getName())>Integer.parseInt(o2.getName())){
                        return 1;
                    }
                    return ‐1;
      }
            });
            return chunkFileList;
        }
        //合并文件
        private File mergeFile(File mergeFile,List<File> chunkFiles){
            try {
                //创建写文件对象
                RandomAccessFile raf_write = new RandomAccessFile(mergeFile,"rw");
                //遍历分块文件开始合并
                //读取文件缓冲区
                byte[] b = new byte[1024];
                for(File chunkFile:chunkFiles){
                    RandomAccessFile raf_read new RandomAccessFile(chunkFile,"r");
                    int len = ‐1;
                    //读取分块文件
                    while((len = raf_read.read(b))!=‐1){
                        //向合并文件中写数据
                        raf_write.write(b,0,len);
                    }
                    raf_read.close();
                }
                raf_write.close();
            } catch (Exception e) {
                e.printStackTrace();
                LOGGER.error("merge file error:{}",e.getMessage());
                return null;
            }
            return mergeFile;
        }
    View Code

    Controller

    @RestController
    @RequestMapping("/media/upload")
    public class MediaUploadController implements MediaUploadControllerApi {
        @Autowired
        MediaUploadService mediaUploadService;
        @Override
         @PostMapping("/register")
        public ResponseResult register(@RequestParam("fileMd5") String fileMd5,
    @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize,
    @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt) {
            return mediaUploadService.register(fileMd5,fileName,fileSize,mimetype,fileExt);
        }
        @Override
        @PostMapping("/checkchunk")
        public CheckChunkResult checkchunk(@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") Integer chunk, @RequestParam("chunkSize") Integer chunkSize) {
            return mediaUploadService.checkchunk(fileMd5,chunk,chunkSize);
        }
        @Override
         @PostMapping("/uploadchunk")
        public ResponseResult uploadchunk(@RequestParam("file") MultipartFile file,
    @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") Integer chunk) {
            return mediaUploadService.uploadchunk(file,fileMd5,chunk);
        }
        @Override
        @PostMapping("/mergechunks")
        public ResponseResult mergechunks(@RequestParam("fileMd5") String fileMd5,
    @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize,
    @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt) {
            return mediaUploadService.mergechunks(fileMd5,fileName,fileSize,mimetype,fileExt);
        }
    }
    View Code

     

  • 相关阅读:
    C# 实现 Snowflake算法生成唯一性Id
    kafka可视化客户端工具(Kafka Tool)的基本使用(转)
    docker 安装kafka
    Model类代码生成器
    使用docker 部署rabbitmq 镜像
    Vue 增删改查 demo
    git 提交代码到库
    Android ble蓝牙问题
    mac 配置 ssh 到git (Could not resolve hostname github.com, Failed to connect to github.com port 443 Operation timed out)
    okhttp
  • 原文地址:https://www.cnblogs.com/anan-java/p/12289437.html
Copyright © 2020-2023  润新知