返回顶部

学成在线(第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:\devenv\edusoft\ffmpeg-20180227-fa0c9d6-win64-static\ffmpeg-20180227-fa0c9d6-win64-static下。
将F:\devenv\edusoft\ffmpeg-20180227-fa0c9d6-win64-static\ffmpeg-20180227-fa0c9d6-win64-static\bin目
录配置在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:\Windows\System32\drivers\etc\hosts文件

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

 

posted @ 2020-02-09 23:39  桃花换了酒钱  阅读(643)  评论(0编辑  收藏  举报
/* * * * 烟花 */