目录
- 背景
- MediaSource
- MP4 视频无法播放
- mimeCodec
- Fragmentation
- 通过 MP4Box 将视频碎片化
- 通过 ffmpeg 格式化
- 分段加载
- 后端代码
- 前端代码
- 验证
背景
打开B站看视频时,发现每个视频点进去后马上播放,并只加载视频一小段,然后在播放同时加载后面一小段,而且在拖动进度条也能快速加载并播放。
本文通过简单demo实现一下视频分段加载并且边播放边加载的效果。
整体思路是,通过 HTTP 请求头中添加 Range 获取视频的一部分数据,然后通过 MediaSource 控制媒体片段加载缓存和播放。
分析过程参考:
通过调试技术,我理清了 b 站视频播放很快的原理
代码参考:
MDN - WebAPI - MediaSource
一步一步学习使用 MediaSource 实现动态媒体流
MediaSource
演示源代码 ,直接全贴出来,加载全部如下
<!DOCTYPE html>
<html><head><meta charset="utf-8"/></head><body><video controls></video><script>var video = document.querySelector('video');var assetURL = 'frag_bunny.mp4';// Need to be specific for Blink regarding codecs// ./mp4info frag_bunny.mp4 | grep Codecvar mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {var mediaSource = new MediaSource;//console.log(mediaSource.readyState); // closedvideo.src = URL.createObjectURL(mediaSource);mediaSource.addEventListener('sourceopen', sourceOpen);} else {console.error('Unsupported MIME type or codec: ', mimeCodec);}function sourceOpen (_) {//console.log(this.readyState); // openvar mediaSource = this;var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);fetchAB(assetURL, function (buf) {sourceBuffer.addEventListener('updateend', function (_) {mediaSource.endOfStream();video.play();//console.log(mediaSource.readyState); // ended});sourceBuffer.appendBuffer(buf);});};function fetchAB (url, cb) {console.log(url);var xhr = new XMLHttpRequest;xhr.open('get', url);xhr.responseType = 'arraybuffer';xhr.onload = function () {cb(xhr.response);};xhr.send();};</script></body>
</html>
播放过程中加载,如下
<!DOCTYPE html>
<html><head><meta charset="utf-8"/></head><body><video controls></video><script>var video = document.querySelector('video');var assetURL = 'frag_bunny.mp4';// Need to be specific for Blink regarding codecs// ./mp4info frag_bunny.mp4 | grep Codecvar mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';var totalSegments = 5;var segmentLength = 0;var segmentDuration = 0;var bytesFetched = 0;var requestedSegments = [];for (var i = 0; i < totalSegments; ++i) requestedSegments[i] = false;var mediaSource = null;if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {mediaSource = new MediaSource;//console.log(mediaSource.readyState); // closedvideo.src = URL.createObjectURL(mediaSource);mediaSource.addEventListener('sourceopen', sourceOpen);} else {console.error('Unsupported MIME type or codec: ', mimeCodec);}var sourceBuffer = null;function sourceOpen (_) {sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);getFileLength(assetURL, function (fileLength) {console.log((fileLength / 1024 / 1024).toFixed(2), 'MB');//totalLength = fileLength;segmentLength = Math.round(fileLength / totalSegments);//console.log(totalLength, segmentLength);fetchRange(assetURL, 0, segmentLength, appendSegment);requestedSegments[0] = true;video.addEventListener('timeupdate', checkBuffer);video.addEventListener('canplay', function () {segmentDuration = video.duration / totalSegments;video.play();});video.addEventListener('seeking', seek);});};function getFileLength (url, cb) {var xhr = new XMLHttpRequest;xhr.open('head', url);xhr.onload = function () {cb(xhr.getResponseHeader('content-length'));};xhr.send();};function fetchRange (url, start, end, cb) {var xhr = new XMLHttpRequest;xhr.open('get', url);xhr.responseType = 'arraybuffer';xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end);xhr.onload = function () {console.log('fetched bytes: ', start, end);bytesFetched += end - start + 1;cb(xhr.response);};xhr.send();};function appendSegment (chunk) {sourceBuffer.appendBuffer(chunk);};function checkBuffer (_) {var currentSegment = getCurrentSegment();if (currentSegment === totalSegments && haveAllSegments()) {console.log('last segment', mediaSource.readyState);mediaSource.endOfStream();video.removeEventListener('timeupdate', checkBuffer);} else if (shouldFetchNextSegment(currentSegment)) {requestedSegments[currentSegment] = true;console.log('time to fetch next chunk', video.currentTime);fetchRange(assetURL, bytesFetched, bytesFetched + segmentLength, appendSegment);}//console.log(video.currentTime, currentSegment, segmentDuration);};function seek (e) {console.log(e);if (mediaSource.readyState === 'open') {sourceBuffer.abort();console.log(mediaSource.readyState);} else {console.log('seek but not open?');console.log(mediaSource.readyState);}};function getCurrentSegment () {return ((video.currentTime / segmentDuration) | 0) + 1;};function haveAllSegments () {return requestedSegments.every(function (val) { return !!val; });};function shouldFetchNextSegment (currentSegment) {return video.currentTime > segmentDuration * currentSegment * 0.8 &&!requestedSegments[currentSegment];};</script></body>
</html>
MP4 视频无法播放
参考这个链接
Transcoding assets for Media Source Extensions
mimeCodec
确认 mimeCodec ,通过代码里 MediaSource.isTypeSupported(mimeCodec)
就知道不是所有的视频都支持,需要预先知道视频 mimeCodec 。
在线检测 mp4info
也可以通过 MP4Box 查看视频信息,官方下载
mp4box -info test001.mp4
找到 RFC6381 Codec Parameters:
值,替换源代码 mimeCodec 的值
Fragmentation
无法播放,查看控制台信息
Uncaught InvalidStateError: Failed to execute 'endOfStream' on 'MediaSource': The MediaSource's readyState is not 'open'. at SourceBuffer.<anonymous>
这是因为我自己测视频不是标准 ISO BMF 格式的 MP4,也就是在 mp4info 信息 is fragmented: false
,而官方示例的视频为 true,表示视频需要是 fragmentation 破碎的。
通过 MP4Box 将视频碎片化
参考 unable-to-get-mediasource-working-with-mp4-format-in-chrome
MP4Box -dash 7000 -rap -frag-rap test001.mp4
上面命令表示按每7秒间隔将视频分段,最后会生成 2 个文件,其中 mpd 文件内容是
通过 ffmpeg 格式化
ffmpeg 官网
ffmpeg -i non_fragmented.mp4 -movflags frag_keyframe+empty_moov+default_base_moof fragmented.mp4
格式化之后的 MP4 文件在官方示例 bufferAll.html 中可以加载播放了
怎么格式化成分段视频待补充…
分段加载
HTTP 请求头中添加 Range 表示要获取视频哪一部分数据,同时也要修改响应头信息
后端代码
后端代码基于 SpringMVC
@GetMapping("/common/file/chunkDownload")public void fileChunkDownload(@RequestParam String fileName, HttpServletRequest request, HttpServletResponse response) throws Exception {Path srcPath = Paths.get("文件路径", fileName);File srcFile = srcPath.toFile();long fSize = srcFile.length();long pos = 0;long last = fSize - 1;// 判断前端是否需要分片下载if (request.getHeader("Range") != null) {response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);String numRange = request.getHeader("Range").replace("bytes=", "");String[] strRange = numRange.split("-");if (strRange.length == 2) {pos = Long.parseLong(strRange[0].trim());last = Long.parseLong(strRange[1].trim());// 若结束字节超出文件大小,取文件大小if (last > fSize - 1) {last = fSize - 1;}} else {// 若只给一个长度,开始位置一直到结束pos = Long.parseLong(numRange.replace("-", "").trim());}}long rangeLength = last - pos + 1;String contentRange = "bytes " + pos + "-" + last + "/" + fSize;// 设置响应头response.setCharacterEncoding("UTF-8");response.setContentType("application/x-download");response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));// 支持分片下载response.setHeader("Accept-Range", "bytes");response.setHeader("fSize", String.valueOf(fSize));response.setHeader("fName", URLEncoder.encode(fileName, "UTF-8"));// range响应头response.setHeader("Content-Range", contentRange);response.setHeader("Content-Length", String.valueOf(rangeLength));// 下载文件try (InputStream is = new BufferedInputStream(Files.newInputStream(srcFile.toPath());OutputStream os = new BufferedOutputStream(response.getOutputStream())) {// 跳过已经读取文件is.skip(pos);byte[] buffer = new byte[1024];long sum = 0;// 读取while (sum < rangeLength) {int length = is.read(buffer, 0, (rangeLength - sum) <= buffer.length ? (int) (rangeLength - sum) : buffer.length);sum = sum + length;os.write(buffer, 0, length);}}}
前端代码
在示例代码基础上实现拖动加载和播放时加载
<!DOCTYPE html>
<html><head><meta charset="utf-8"/></head><body><video controls></video><script>var video = document.querySelector('video');// 这里模拟已经拿到视频分段信息的情况下//var assetURL = 'http://localhost:10004/haoke/common/file/chunkDownload?fileName=frag_bunny.mp4';var assetURL = 'http://localhost:10004/haoke/common/file/chunkDownload?fileName=test001_dashinit.mp4';// Need to be specific for Blink regarding codecs// ./mp4info frag_bunny.mp4 | grep Codec//var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';var mimeCodec = 'video/mp4; codecs="avc1.64001F, mp4a.40.2"';var totalSegments = 6;var segmentLength = 0;var segmentDuration = 0;var requestedSegments = [];var segments = [{start:0,end:1541631},{start:1541632,end:2949315},{start:2949316,end:4416431},{start:4416432,end:5850902},{start:5850903,end:7245487},{start:7245488,end:7410081}];for (var i = 0; i < totalSegments; ++i) requestedSegments[i] = false;var mediaSource = null;if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {mediaSource = new MediaSource;//console.log(mediaSource.readyState); // closedvideo.src = URL.createObjectURL(mediaSource);mediaSource.addEventListener('sourceopen', sourceOpen);} else {console.error('媒体类型不支持播放', mimeCodec);}var sourceBuffer = null;function sourceOpen (_) {sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);// 直接加载第一段fetchAndAddSegment(0);// 视频播放的时候会触发该时间video.addEventListener('timeupdate', checkBuffer);// 视频就绪可以播放时会触发该事件video.addEventListener('canplay', function () {// video.duration 是视频的总时长,segmentDuration是每个分段的持续时间segmentDuration = video.duration / totalSegments;});// 跳跃到新位置时触发video.addEventListener('seeking', seek);video.addEventListener('waiting', dealWaiting)mediaSource.removeEventListener('sourceopen', sourceOpen);};var isUpdating = false;// 获取视频指定分段async function fetchAndAddSegment(index) {if(isUpdating) return;if(index >= 0 && index < totalSegments && !haveAllSegments() && !sourceBuffer.updating){// 加锁isUpdating = true;let res = await fetch(assetURL,{headers:{ 'Range':`bytes=${segments[index].start}-${segments[index].end}` }})let data = await res.arrayBuffer()requestedSegments[index] = true;sourceBuffer.appendBuffer(data);// 解锁isUpdating = false;}};// 检查是否需要请求新的段function checkBuffer(){var nextSegment = getNextSegment();if(nextSegment >= totalSegments && haveAllSegments()) {if(mediaSource.readyState === 'open'){ mediaSource.endOfStream(); }video.removeEventListener('timeupdate', checkBuffer);video.removeEventListener('seeking', seek)video.removeEventListener('waiting', dealWaiting);}else if(shouldFetchNextSegment(nextSegment)){fetchAndAddSegment(nextSegment);}};var shouldToSegment = 0; var dealingSeeking = false; // 进度条人为改变时触发const seek = ()=>{console.log('seek')if(haveAllSegments() || mediaSource.readyState != 'open'){ return;}else{// 当前的时间节点const currentTime = video.currentTime;// 应该追加到第几段let newShouldToSegment = Math.ceil(currentTime / segmentDuration / 0.5 + 1);// 是否应该获取更多的片段if(newShouldToSegment <= shouldToSegment) {return;} else {shouldToSegment = newShouldToSegment < totalSegments - 2 ? newShouldToSegment : totalSegments - 1;}if(dealingSeeking || haveAllSegments()){ return; }else{// 加锁dealingSeeking = true;let i = 0;// 等待上一次更新完while(sourceBuffer.updating){ console.log(sourceBuffer.updating);i++;if(i > 1000) return;}// 移除进度条发生变化时的监听事件,避免冲突video.removeEventListener('timeupdate', checkBuffer);// 持续检查并获取视频流片段const continueRequestSegment = ()=>{checkBuffer()let nextSegment = getNextSegment();if(nextSegment > shouldToSegment && requestedSegments[nextSegment - 1] || haveAllSegments()){sourceBuffer.removeEventListener('updateend', continueRequestSegment);if(!haveAllSegments()){console.log('重新添加 timeupdate 事件')video.addEventListener('timeupdate', checkBuffer);}// 解锁dealingSeeking = false;}}// 先添加 buffer 追加完成事件sourceBuffer.addEventListener('updateend', continueRequestSegment)// 检查完成后,如果需要请求新的分段,那么会在追加完成新的buffer后触发上面的 updateend 事件checkBuffer();}}}// 如果出现等待const dealWaiting = () =>{checkBuffer();video.addEventListener('timeupdate', checkBuffer);}// 获取下一个应该请求的分段const getNextSegment = () => {return requestedSegments.lastIndexOf(true) + 1}// 是否已获取完所有的分段const haveAllSegments = ()=> {return !requestedSegments.includes(false)}// 判断是否应该获取下一段 播放超过当前段一半时需要获取function shouldFetchNextSegment(nextSegment) {return (video.currentTime > segmentDuration * (nextSegment - 0.5)&& !requestedSegments[nextSegment] && nextSegment < totalSegments) || !requestedSegments[1];};</script></body>
</html>
验证
最后验证一下视频播放和加载,正常