zoukankan      html  css  js  c++  java
  • 学成在线(第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

     

  • 相关阅读:
    Vue的使用
    Bootstrap 提示工具(Tooltip)插件
    基于layerpage 前后端异步分页
    bootstrap的selectpicker的方法
    移动端好用的下拉加载上拉刷新插件 dropload插件
    vue的安装
    chromium ②
    chromium ①
    一些技术博客 集合
    提高pv uv
  • 原文地址:https://www.cnblogs.com/anan-java/p/12289437.html
Copyright © 2011-2022 走看看