扩展_流媒体
流媒体
流媒体(streaming media)是指将一连串的媒体数据压缩后,经过网上分段发送数据,在网上即时传输影音以供观赏的一种技术与过程,此技术使得数据包得以像流水一样发送;如果不使用此技术,就必须在使用前下载整个媒体文件。
流媒体文件一般定义在bit层次结构,因此流数据包并不一定必须按照字节对齐,虽然通常的媒体文件都是按照这种字节对齐的方式打包的。流媒体的三大操作平台是微软公司、RealNetworks、苹果公司提供的。
点播的方案
1、 播放器通过 http协议从http服务器上下载视频文件进行播放
问题:必须等到视频下载完才可以播放,不支持快进到某个时间点进行播放
2、 播放器通过rtmp协议连接媒体服务器以实时流方式播放视频
使用rtmp协议需要架设媒体服务器
,造价高,对于直播多采用此方案。
3、 播放器使用HLS协议连接http服务器(Nginx、Apache等)实现近实时流方式播放视频
HLS协议规定:基于Http协议,视频封装格式为ts,视频的编码格式为H264,音频编码格式为MP3、AAC或者AC-3。
HLS(HTTP Live Streaming)
HLS的工作方式是:将视频拆分成若干ts格式的小文件,通过m3u8格式的索引文件对这些ts小文件建立索引。一般10秒一个ts文件,播放器连接m3u8文件播放,当快进时通过m3u8即可找到对应的索引文件,并去下载对应的ts文件,从而实现快进、快退以近实时 的方式播放视频。
IOS、Android设备、及各大浏览器都支持HLS协议。
音视频编码
音视频编码格式:通过音视频的压缩技术,将视频格式转换成另一种视频格式,通过视频编码实现流媒体的传输。
比如:一个.avi的视频文件原来的编码是a,通过编码后编码格式变为b,音频原来为c,通过编码后变为d。
MPEG系列 (由ISO[国际标准组织机构]下属的MPEG[运动图象专家组]开发 )视频编码方面主要是Mpeg1(vcd用的就是它)、Mpeg2(DVD使用)、Mpeg4(的DVDRIP使用的都是它的变种,如:divx,xvid等)、Mpeg4 AVC(正热门);音频编码方面主要是MPEG Audio Layer 1/2、MPEG Audio Layer 3(大名鼎鼎的mp3)、MPEG-2 AAC 、MPEG-4 AAC等等。注意:DVD音频没有采用Mpeg的。
FFmpeg
多媒体视频处理工具FFmpeg有非常强大的功能包括视频采集功能、视频格式转换、视频抓图、给视频加水印等。
下载:FFmpeg https://www.ffmpeg.org/download.html#build-windows
FFmpeg使用
(1)将lucene.avi文件转成mp4
ffmpeg -i lucene.avi lucene.mp4
(2)将lucene.avi文件转成mp3
ffmpeg -i lucene.avi lucene.mp3
(3)将lucene.avi文件转成git
ffmpeg -i lucene.avi lucene.gif
使用ffmpeg生成 m3u8
(1)将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
备注:
-c:v 视频编码为x264 ,x264编码是H264的一种开源编码格式。
-s 设置分辨率。
-pix_fmt yuv420p:设置像素采样方式,主流的采样方式有三种,YUV4:4:4,YUV4:2:2,YUV4:2:0,它的作用是根据采样方式来从码流中还原每个像素点的YUV(亮度信息与色彩信息)值。
-b 设置码率
,-b:a和-b:v分别表示音频的码率和视频的码率,-b表示音频加视频的总码率。码率对一个视频质量有很大的作用。
-r:帧率,表示每秒更新图像画面的次数,通常大于24肉眼就没有连贯与停顿的感觉了。
(2)将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的索引文件。
(3)VLC打开m3u8文件
使用VLC打开m3u8文件,测试播放效果,VLC 是一款自由、开源的跨平台多媒体播放器及框架,可播放大多数多媒体文件,以及 DVD、音频 CD、VCD 及各类流媒体协议。(http://www.videolan.org/)
码率的设置
码率又叫比特率即每秒传输的bit数,单位为bps(Bit Per Second),码率越大传送数据的速度越快。
码率的计算公式是:文件大小(转成bit)/ 时长(秒)/1024 = kbps 即每秒传输千位数。
例如一个1M的视频,它的时长是10s,它的码率等于 110241024*8/10/1024 = 819Kbps。
如果要将视频上传到优酷则必须按照上面的要求,如果是自己搭建视频服务器,码率设置不易过大,最终达到的视频清晰度满足业务需求即可。
播放器
在web应用中常用的播放器
(1)flash播放器:缺点是需要在客户机安装Adobe Flash Player播放器,优点是flash播放器已经很成熟了,并且浏览器对flash支持也很好。
(2)H5播放器:基于h5自带video标签进行构建,优点是大部分浏览器支持H5,不用再安装第三方的flash播放器,并且随着前端技术的发展,h5技术会越来越成熟。
H5播放器-Video.js
Video.js是一款基于HTML5世界的网络视频播放器。它支持HTML5和Flash视频,它支持在台式机和移动设备上播放视频。这个项目于2010年中开始,目前已在40万网站使用。
官方地址:http://videojs.co
视频服务器
upstream video_server_pool{
server 192.168.100.2:90 weight=10;
server 192.168.100.3:90 weight=10;
}
map $http_origin $origin_list {
default http://www.pingruan.com;
"~http://www.pingruan.com" http://www.pingruan.com;
"~http://ucenter.pingruan.com" http://ucenter.pingruan.com;
}
server{
listen 90;
server_name video.pingruan.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; #允许客户端跨域访问的方法
}
}
server{
listen 90;
server_name localhost;
location /video/{
alias /develop/video/;
}
}
server{
listen 90;
server_name localhost;
location /video/{
alias E:/develop/video/;
}
}
播放页面Demo
<!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>
断点续传
前段
WebUploader
官方:http://fex.baidu.com/webuploader/document.html
<script>
import $ from '../../../../static/plugins/jquery/dist/jquery.js'
import webuploader from '../../../../static/plugins/webuploader/dist/webuploader.js'
import '../../../../static/css/webuploader/webuploader.css'
export default{
data(){
return{
uploader:{},
uploadFile:{},
percentage:0,
fileMd5:''
}
},
methods:{
//开始上传
upload(){
if(this.uploadFile && this.uploadFile.id){
this.uploader.upload(this.uploadFile.id);
}else{
alert("请选择文件");
}
}
},
mounted(){
// var fileMd5;
// var uploadFile;
WebUploader.Uploader.register({
"before-send-file":"beforeSendFile",
"before-send":"beforeSend",
"after-send-file":"afterSendFile"
},{
beforeSendFile:function(file) {
// 创建一个deffered,用于通知是否完成操作
var deferred = WebUploader.Deferred();
// 计算文件的唯一标识,用于断点续传
(new WebUploader.Uploader()).md5File(file, 0, 100*1024*1024)
.then(function(val) {
this.fileMd5 = val;
this.uploadFile = file;
// alert(this.fileMd5 )
//向服务端请求注册上传文件
$.ajax(
{
type:"POST",
url:"/api/media/upload/register",
data:{
// 文件唯一表示
fileMd5:this.fileMd5,
fileName: file.name,
fileSize:file.size,
mimetype:file.type,
fileExt:file.ext
},
dataType:"json",
success:function(response) {
if(response.success) {
//alert('上传文件注册成功开始上传');
deferred.resolve();
} else {
alert(response.message);
deferred.reject();
}
}
}
);
}.bind(this));
return deferred.promise();
}.bind(this),
beforeSend:function(block) {
var deferred = WebUploader.Deferred();
// 每次上传分块前校验分块,如果已存在分块则不再上传,达到断点续传的目的
$.ajax(
{
type:"POST",
url:"/api/media/upload/checkchunk",
data:{
// 文件唯一表示
fileMd5:this.fileMd5,
// 当前分块下标
chunk:block.chunk,
// 当前分块大小
chunkSize:block.end-block.start
},
dataType:"json",
success:function(response) {
if(response.fileExist) {
// 分块存在,跳过该分块
deferred.reject();
} else {
// 分块不存在或不完整,重新发送
deferred.resolve();
}
}
}
);
//构建fileMd5参数,上传分块时带上fileMd5
this.uploader.options.formData.fileMd5 = this.fileMd5;
this.uploader.options.formData.chunk = block.chunk;
return deferred.promise();
}.bind(this),
afterSendFile:function(file) {
// 合并分块
$.ajax(
{
type:"POST",
url:"/api/media/upload/mergechunks",
data:{
fileMd5:this.fileMd5,
fileName: file.name,
fileSize:file.size,
mimetype:file.type,
fileExt:file.ext
},
success:function(response){
//在这里解析合并成功结果
if(response && response.success){
alert("上传成功")
}else{
alert("上传失败")
}
}
}
);
}.bind(this)
}
);
// 创建uploader对象,配置参数
this.uploader = WebUploader.create(
{
swf:"/static/plugins/webuploader/dist/Uploader.swf",//上传文件的flash文件,浏览器不支持h5时启动flash
server:"/api/media/upload/uploadchunk",//上传分块的服务端地址,注意跨域问题
fileVal:"file",//文件上传域的name
pick:"#picker",//指定选择文件的按钮容器
auto:false,//手动触发上传
disableGlobalDnd:true,//禁掉整个页面的拖拽功能
chunked:true,// 是否分块上传
chunkSize:1*1024*1024, // 分块大小(默认5M)
threads:3, // 开启多个线程(默认3个)
prepareNextFile:true// 允许在文件传输时提前把下一个文件准备好
}
);
// 将文件添加到队列
this.uploader.on("fileQueued", function(file) {
this.uploadFile = file;
this.percentage = 0;
}.bind(this)
);
//选择文件后触发
this.uploader.on("beforeFileQueued", function(file) {
// this.uploader.removeFile(file)
//重置uploader
this.uploader.reset()
this.percentage = 0;
}.bind(this));
// 监控上传进度
// percentage:代表上传文件的百分比
this.uploader.on("uploadProgress", function(file, percentage) {
this.percentage = Math.ceil(percentage * 100);
}.bind(this));
//上传失败触发
this.uploader.on("uploadError", function(file,reason) {
console.log(reason)
alert("上传文件失败");
});
//上传成功触发
this.uploader.on("uploadSuccess", function(file,response ) {
console.log(response)
// alert("上传文件成功!");
});
//每个分块上传请求后触发
this.uploader.on( 'uploadAccept', function( file, response ) {
if(!(response && response.success)){//分块上传失败,返回false
return false;
}
});
}
}
</script>
后端
业务流程
服务端需要实现如下功能:
1、上传前检查上传环境
检查文件是否上传,已上传则直接返回。
检查文件上传路径是否存在,不存在则创建。
2、分块检查
检查分块文件是否上传,已上传则返回true。
未上传则检查上传路径是否存在,不存在则创建。
3、分块上传
将分块文件上传到指定的路径。
4、合并分块
将所有分块文件合并为一个文件。
在数据库记录文件信息。
Entity
@Data
@ToString
@Document(collection = "media_file")
public class MediaFile {
/*
文件id、名称、大小、文件类型、文件状态(未上传、上传完成、上传失败)、上传时间、视频处理方式、视频处
理状态、hls_m3u8,hls_ts_list、课程视频信息(课程id、章节id)
*/
@Id
//文件id
private String fileId;
//文件名称
private String fileName;
//文件原始名称
private String fileOriginalName;
//文件路径
private String filePath;
//文件url
private String fileUrl;
//文件类型
private String fileType;
//mimetype
private String mimeType;
//文件大小
private Long fileSize;
//文件状态
private String fileStatus;
//上传时间
private Date uploadTime;
}
Dao
public interface MediaFileRepository extends MongoRepository<MediaFile,String> {
}
Service
@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);
}
//分片检查----得到块文件所在目录
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);
}
//上传分块-----块文件上传
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;
}
//合并块------文件
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;
}
}
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);
}
}