关于WEB无插件播放视频的实现
1 背景
当前业内实现WEB视频播放的方式主要有:
1) 使用OCX 插件
2) 使用 flash 插件
3) 使用HTML5 的 Video 标签
4) 使用canvas实时绘制图片模拟
优缺点分析:
1) 兼容性较差,由于ActiveX控件逐渐被各大浏览器淘汰,该方式仅可在IE浏览器以及早期版本的chrome浏览器(45版本以下),且用户使用时需先下载并安装第三方插件,用户体验较差,其优点是视频播放清晰度、流畅度高,可实现视频流的实时预览等。
2) 兼容性一般,与ocx插件类似,因为安全性漏洞与日益下降的功能竞争力,flash插件也逐步行进到被各大浏览器抛弃的边缘,以Chrome、Firefox为首的浏览器厂商都已陆续宣布停止对flash的支持,虽然当前仍然有很大部分用户使用兼容flash旧版本的浏览器,但随着技术的发展,flash被淘汰是大势所趋。
3) 兼容性较好,在各大主流浏览器,如Chrome、Firefox中兼容情况良好,但在IE浏览器中仅支持IE9以上的版本,其功能接口较为完善,但对视频源的格式有一定要求,仅支持标准的MP4、webM、ogg文件等,无法直接播放实时的视频流。
4) 兼容性一般,canvas本身的兼容性较好,但以这种方式实现最好通过websocket建立长连接,清晰、流畅度受带宽等外界因素影响较大,对服务器的要求较高,开发成本高,需自定义、开发功能接口。
| 兼容性 | 清晰、流畅度 | 可维护性 |
OCX插件 | √ | √√√ | √ |
flash插件 | √√ | √√√ | √ |
Video | √√√ | √√√ | √√√ |
canvas | √√√ | √√ | √√ |
2 需求分析
在该项目中,服务器端存储有未经过转码的视频文件,大小不一,视文件时长,小至数M,大至约2G,该类文件非标准MP4文件,使用前需经过转码库转码,该过程在服务端进行,客户端的主要工作为:在WEB页面中嵌入一个视频播放器,以实现视频文件的播放、暂停、快进、慢放、下载、全屏播放、跳转进度等功能。
由此可知,该项目对播放性的功能需求要求较少,相应对性能的需求要求较高,结合上文中对四种播放方式的对比,故采用Video标签的方式实现。
3 可行性分析
由上文可知,Video标签仅能播放标准的MP4文件,因此视频文件使用之前需先通过服务器转码生成标准的MP4文件。Video标签的视频源获取方式有三种:
1) 以xxx.mp4结尾的静态资源地址,
2) 以请求url作为视频源地址,服务器在响应该请求时,在响应体reponse中写入文件的字符流。
3) 以MediaSource对象作为播放地址,通过websocket建立长连接实时获取视频数据,动态添加。
下面将分别针对三种方式作简要分析:
第一种方式的前提是视频文件需在WEB的项目根目录的同级或子级路径下,由于该项目中视频文件的存储位置可由管理员配置,因此视频文件无法固定在指定的目录下。若在播放时由服务器拷贝视频文件至WEB根目录下,则会产生额外的存储资源开销,且当多位用户同时访问大文件时,此存储资源开销的峰值较大,容易造成磁盘空间不足等异常情况,因此,以该种方式实现的难点在于如何在不增加存储资源开销的情况下让客户端访问到存储位置未定的静态资源文件。
解决此问题的一个方案是在视频文件实际的存储目录与web的静态资源目录之间建立符号链接(又称软链接),其特点是:
a 在系统中不占用空间
b 若源文件被删除则链接失效
c 若源文件移动到其他位置,链接仍然生效
d 若删除链接,不影响源文件
其中d:\srcDir为源文件夹,d:\targetDir为目标文件夹(WEB根目录),
/D字段表示建立链接的文件夹,不加/D则在两个文件之间建立软链接
目标文件夹会自动生成但不会覆盖,因此若目标文件夹已存在则命令无法执行。
通过软链接的方式在目标文件夹生成了web文件夹,但目标文件夹自身的大小并没有因此而增加。
第二种方式没有静态资源存储位置的限制,客户端将请求url直接作为播放地址,服务端在接受到请求后,需要开启资源文件的输入流,同时开启响应对象的输出流,循环读取文件数据并在响应体中写入文件的字符流即可实现播放。实现方式与关键代码如下文所示:
客户端代码实现:
SHAPE \* MERGEFORMAT <video id="video"> <source src="/getVideo"></source> </video>
服务端代码实现(以Java的springboot框架为例):
SHAPE \* MERGEFORMAT @RequestMapping("/getVideo") public void getVideo(HttpServletRequest req, HttpServletResponse res) throws IOException{ File file = new File("D://movie.mp4"); res.setHeader("content-type", "video/mp4"); res.setHeader("Content-Disposition", "attachment;filename= movie.mp4"); res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Content-Length", String.valueOf(file.length())); byte[] buff = new byte[1024]; BufferedInputStream bis = null; OutputStream os = null; try { os = res.getOutputStream(); bis = new BufferedInputStream(new FileInputStream(file)); int i = 0; do { i = bis.read(buff); os.write(buff, 0, buff.length); os.flush(); }while (i != -1); } catch (IOException e) { e.printStackTrace(); } finally { if (bis != null) { try { bis.close(); } catch (IOException e) { e.printStackTrace(); } } } }
需要注意的是:响应头中的字段必须配置正确,其中content-type字段需设置为video/mp4,否则在IE浏览器下可能产生兼容性问题而无法播放,content-length同样为必须字段,若该字段缺失会导致video播放器器的进度条失效。
然而,此种方式在播放较大的文件时仍然存在问题,即播放大文件时,虽然在响应头中设置了content-length字段,但在客户端实际抓包时发现该字段并未生效,而是被Transfer-Encoding:chunked字段替代。原因在于:当不能预先确定报文响应体的长度或者报文响应体的长度过大时,无法使用content-length字段来指明报文长度,需要通过Transfer-Encoding域来替代。
Transfer-Encoding:chunked用于http传送过程的分块传输技术,原因是http服务器响应的报文长度经常是不可预测的,使用content-length的实体搜捕并不是总是管用。分块技术的意思是说,实体被分成许多的块(Data chunk),也就是应用层的数据,TCP在传送的过程中,不对它们做任何的解释,而是把应用层产生数据全部理解成二进制流,然后按照一定的长度切成一段的,然后一次性给TCP协议去传输,而具体这些二进制的数据如何做解释,需要应用层来完成,所以在这之前,一快整体应用层的数据需要等它分成的所有TCP segment到达对方,重新组装后,应用程序才使用自己的解码方法还原它们。
另外,对于大文件的播放,方式一与方式二有一个共同的缺点,即两种方式皆是在浏览器缓存到整个文件的数据后,video播放器才会开始播放画面,也就意味着,客户端加载视频文件的等待时间会格外的长,对于2G左右的文件,在带宽不足的情况下甚至会长达半分钟,毫无疑问这对用户体验是一个重大打击。当然在播放地址固定的情况下,可以对video标签设置proload属性来提前开始缓存视频资源,但在该项目中,视频源的地址需要动态生成,因此针对此种情况需要另做优化。
对于上文中提到的大文件播放的问题,可以使用切割视频文件的方式得以解决,即按照指定时长(比如30秒)将一个视频大文件切割成若干个文件片段。通过自定义封装视频播放进度条,在视频文件的播放时长已知的情况下,当用户拖动进度条时可计算当前播放位置相对总播放时长的百分比来获取总播放进度,并计算出应当返回的文件片段下标,从而动态生成视频源地址,并在该片段播放结束后以下一段文件片段作为播放地址,实现连续播放。该种方式解决了大文件的播放问题,但同样存在其他不足。其实现难点在于视频的分割、自定义进度条的实现,以及文件片段衔接处的流畅性。
首先是视频分割,因为文件头中存在文件信息的描述,因此无法直接截断字符流分批发送,可以借助第三方库ffmpeg来实现文件的分割,其命令格式如下:
SHAPE \* MERGEFORMAT ffmpeg -i source.mp4 -vcodec copy -acodec copy -ss 00:00:10 -to 00:00:15 target.mp4 -y
其中source.mp4表示源视频文件,target.mp4表示生成的目标文件,该段命令的含义为:创建target.mp4文件并读取source.mp4文件的第10到第15秒写入target.mp4中。服务端可通过调用命令行的方式,在收到播放请求时动态切割生成文件片段,并将其以字符流的形式写入响应体中。
服务端调用命令行调用ffmpeg分割视频代码(以Java的springboot框架为例):
SHAPE \* MERGEFORMAT List<String> command = new ArrayList<String>(); command.add("ffmpeg"); command.add("-i"); command.add(srcPath); command.add("-vcodec"); command.add("copy"); command.add("-acodec"); command.add("copy"); command.add("-ss"); command.add("00:00:10"); command.add("-to"); command.add("00:00:50"); command.add(tarPath); command.add("-y"); ProcessBuilder builder = new ProcessBuilder(command); try { Process process = builder.start(); final StringBuilder stringBuilder = new StringBuilder(); final InputStream inputStream = process.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); String line; try { while ((line = reader.readLine()) != null) { stringBuilder.append(line); } reader.close(); } catch (IOException e) { e.printStackTrace(); } process.waitFor(); } catch (Exception e) { throw new RuntimeException("ffmpeg执行异常" + e.getMessage()); }
需要注意的是,本例中给出的代码是以时间节点作为分割依据,因为视频文件的特殊性,以关键帧作为分割依据效果更佳。自定义进度条的实现可使用javascript在客户端开启定时器,调用video标签提供的currentTime接口,实时获取当前片段文件的播放进度,并根据当前片段文件的下标计算得出总播放进度,重新渲染DOM元素。为提高衔接处的流畅性,可使用两个甚至多个video标签,分别以相邻的片段文件作为视频源地址,设置proload属性提前加载文件资源,在上一段片段文件播放完毕时切换成下一个video标签继续播放,由于视觉残留效应,衔接处的停顿可以忽略不计。
第三种方式是以websocket + MSE的方式实现取流播放。MSE(Media Source Extensions)是Chrome、Safari、Edge等主流浏览器支持的一个新的Web API,其允许JavaScript动态创建video和audio标签的媒体流,它定义了一个MediaSource对象来给HTMLMediaElement提供媒体数据的源。MediaSource对象拥有一个或多个SourceBuffer对象。浏览器应用通过添加数据片段给SourceBuffer对象,然后根据系统性能和其他因素来适应不同质量的后续数据流。SourceBuffer对象里的数据被组建成需要被编码和播放的音频、视频和文字信息的轨道缓冲格式。
具体的实现方式为:首先创建一个MediaSource对象并将其作为video标签的视频源地址,然后通过websocket获取视频流数据,使用MediaSource的addSourceBuffer方法动态添加视频流数据,客户端的实现代码如下:
SHAPE \* MERGEFORMAT var video = document.getElementById('video') var wsURL = 'ws://localhost:8088/' var mimeCodec = 'video/webm; codecs="vorbis,vp8"' if('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)){ var mediaSource = new MediaSource(); mediaSource.addEventListener('sourceopen', sourceOpen) video.src = URL.createObjectURL(mediaSource) } function sourceOpen(){ var mediaSource = this var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec) fetchMedia(wsURL, function(buf){ sourceBuffer.addEventListener('updateend', function(){ video.play() }) sourceBuffer.appendBuffer(buf) }) } function fetchMedia(url, cb){ var websocket = new WebSocket(url) websocket.binaryType = 'arraybuffer' websocket.onopen = function() { websocket.send("start'); }; websocket.onmessage = function(e) { cb(e.data); }; }
因为mp4文件是以嵌套box的形式存在,每个box包含header和data,其中header描述了该box的长度等信息,因此取流时服务端需要做更多的操作,故本例中使用了webm文件作为源视频文件,其基于Matroska的容器格式更适合于流媒体。若使用其他格式的文件,例中的mimeCodec也需要做相应的修改。
4不足与优化方向
1 背景
本文旨在提出在web网页中播放视频文件的解决方案,并给出几种版本的代码实现。
在本文的前一篇“关于WEB无插件播放视频的实现”中提到,当前业内实现WEB视频播放的方式主要有:使用OCX 插件、使用 flash 插件、使用HTML5 的 Video 标签、使用canvas实时绘制图片模拟,并分析了这几种方式的优缺点及适合的使用场景。
根据项目的实际情况以及当前的技术水平,本方案将采用HTML5 的 Video 标签播放mp4文件。
2 难点分析
在该项目中,服务器端存储有未经过转码的视频文件,大小不一,视文件时长,小至数M,大至约2G,该类文件非标准MP4文件,使用前需经过转码库转码,该过程在服务端进行,客户端的主要工作为:在WEB页面中嵌入一个视频播放器,以实现视频文件的播放、暂停、快进、慢放、下载、全屏播放、跳转进度等功能。
Figure SEQ Figure \* ARABIC 1 时序图1
但如此则加大了服务器端的负荷,并且需要客户端在修改拖动修改进度的时候向服务端发送偏移量,而原生的video标签并不具备此项功能。
更好的做法是,在客户端发送播放请求时,服务端开启转码工作,并生成一个临时文件,读取该临时文件的文件流发送给客户端,这样即便有多个用户请求播放同一个视频文件时,也无需多次执行转码。其工作的时序图如下:
Figure SEQ Figure \* ARABIC 2 时序图2
然而,此种方式在播放较大的文件时仍然存在问题,即播放大文件时,虽然在响应头中设置了content-length字段,但在客户端实际抓包时发现该字段并未生效,而是被Transfer-Encoding:chunked字段替代。原因在于:当不能预先确定报文响应体的长度或者报文响应体的长度过大时,无法使用content-length字段来指明报文长度,需要通过Transfer-Encoding域来替代。
Transfer-Encoding:chunked用于http传送过程的分块传输技术,原因是http服务器响应的报文长度经常是不可预测的,使用content-length的实体搜捕并不是总是管用。分块技术的意思是说,实体被分成许多的块(Data chunk),也就是应用层的数据,TCP在传送的过程中,不对它们做任何的解释,而是把应用层产生数据全部理解成二进制流,然后按照一定的长度切成一段的,然后一次性给TCP协议去传输,而具体这些二进制的数据如何做解释,需要应用层来完成,所以在这之前,一快整体应用层的数据需要等它分成的所有TCP segment到达对方,重新组装后,应用程序才使用自己的解码方法还原它们。
因为video标签的请求处理是浏览器原生实现,无法通过js代码强行修改,因此实现大文件播放的主要工作是修改服务器的响应逻辑,迎合video标签发出的请求。
3 代码实现
服务端响应大文件播放请求的关键在于对响应头中的“Content-Length”和“Content-Range”属性以及请求状态码的处理。
播放小文件(一次请求就可传输完整个视频文件内容)的时候,仅需将“Content-Length”设置为文件的大小,并将状态码设置为“200”即可正常播放。但在播放大文件的时候,一次请求无法传输整个视频文件,客户端会与服务端进行多次请求直至完成加载整个视频文件。服务端需要在客户端发出第一次请求后,设置“Content-Length”设置为文件的大小,并设置“Content-Range”,具体的格式为“bytes 0-(f-1)/f”,其中变量f为文件的大小,例如视频文件大小为 96361162,则“Content-Range”的值应当设置为“bytes 0-96361161/96361162”,同时,需要将状态码设置为“206”。
如此客户端可以得知文件的总大小(此过程中可能还包括头文件的内容,因条件所限未做验证,但不影响代码实现),并据此放弃第一次请求,开始分多次加载整个文件的内容。Video之后的每次请求都修改请求头的“Range”属性,这个属性代表偏移量,当用户拖动进度条向服务端传递偏移量时也是通过这个属性。
“Range”属性的格式通常为“byte=a-b”, 其中a代表起始偏移量,b代表终止偏移量,b-a则表示本次请求需要获取的文件块大小。服务端需要根据“Range”属性设置相应的响应头属性“Content-Length”与“Content-Range”,具体规则为:
1、“Content-Length”设置为b减去a加1
2、“Content-Range”设置为“bytes a-b/f”,f为文件的总大小
3、a与b皆为可选参数,但至少要有一个,若a为空,则默认为f减去b,若b为空,则默认为f减去1。
流程图如下:
Figure 3 服务端请求处理流程图
由此可以看出video第一次发送请求时,请求头中的“Range”属性为“byte=0-”,事实上也确实如此。此外,服务端本次请求回传给客户端的文件内容大小也需要遵循此规则。
下面给出NodeJS版与java版的服务器代码实现:
NodeJS版本:
// 初始化需要的对象
var http = require("http");
var fs = require("fs");
var path = require("path");
var url = require("url");
// 初始的目录
//var initFolder = "D:\\work\\node\\workspace\\video";
var initFolder = process.cwd()
// MIME字典
var mimeNames = {
".css": "text/css",
".html": "text/html",
".js": "application/javascript",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "application/ogg",
".ogv": "video/ogg",
".oga": "audio/ogg",
".txt": "text/plain",
".wav": "audio/x-wav",
".webm": "video/webm"
};
// 创建服务,监听8080端口
http.createServer(httpListener).listen(8080);
// 发送响应
function sendResponse(response, responseStatus, responseHeaders, readable) {
response.writeHead(responseStatus, responseHeaders);
if (readable == null)
response.end();
else
readable.on("open", function () {
readable.pipe(response);
});
return null;
}
function getMimeNameFromExt(ext) {
var result = mimeNames[ext.toLowerCase()];
//默认值
if (result == null) result = "application/octet-stream";=
return result;
}
function readRangeHeader(range, totalLength) {
if (range == null || range.length == 0) return null;
var array = range.split(/bytes=([0-9]*)-([0-9]*)/);
var start = parseInt(array[1]);
var end = parseInt(array[2]);
var result = {
Start: isNaN(start) ? 0 : start,
End: isNaN(end) ? (totalLength - 1) : end
};
if (!isNaN(start) && isNaN(end)) {
result.Start = start;
result.End = totalLength - 1;
}
if (isNaN(start) && !isNaN(end)) {
result.Start = totalLength - end;
result.End = totalLength - 1;
}
return result;
}
// 监听http请求
function httpListener(request, response) {
// We will only accept 'GET' method. Otherwise will return 405 'Method Not Allowed'.
if (request.method != 'GET') {
sendResponse(response, 405, { 'Allow': 'GET' }, null);
return null;
}
var filename =
initFolder + url.parse(request.url, true, true).pathname.split('/').join(path.sep);
// Check if file exists. If not, will return the 404 'Not Found'.
if (!fs.existsSync(filename)) {
sendResponse(response, 404, null, null);
return null;
}
var responseHeaders = {};
var stat = fs.statSync(filename);
var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
// If 'Range' header exists, we will parse it with Regular Expression.
if (rangeRequest == null) {
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Content-Length'] = stat.size; // File size.
responseHeaders['Accept-Ranges'] = 'bytes';
// If not, will return file directly.
sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
return null;
}
var start = rangeRequest.Start;
var end = rangeRequest.End;
// If the range can't be fulfilled.
if (start >= stat.size || end >= stat.size) {
// Indicate the acceptable range.
responseHeaders['Content-Range'] = 'bytes */' + stat.size; // File size.
// Return the 416 'Requested Range Not Satisfiable'.
sendResponse(response, 416, responseHeaders, null);
return null;
}
// Indicate the current range.
responseHeaders['Content-Range'] = 'bytes ' + start + '-' + end + '/' + stat.size;
responseHeaders['Content-Length'] = start == end ? 0 : (end - start + 1);
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Accept-Ranges'] = 'bytes';
responseHeaders['Cache-Control'] = 'no-cache';
// Return the 206 'Partial Content'.
console.log(start,end)
sendResponse(response, 206,
responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
}