目录
-
- 背景
- MediaSource
- [MP4 视频无法播放](#MP4 视频无法播放)
-
- mimeCodec
- Fragmentation
-
- [通过 MP4Box 将视频碎片化](#通过 MP4Box 将视频碎片化)
- [通过 ffmpeg 格式化](#通过 ffmpeg 格式化)
- 分段加载
- 验证
背景
打开B站看视频时,发现每个视频点进去后马上播放,并只加载视频一小段,然后在播放同时加载后面一小段,而且在拖动进度条也能快速加载并播放。
本文通过简单demo实现一下视频分段加载并且边播放边加载的效果。
整体思路是,通过 HTTP 请求头中添加 Range 获取视频的一部分数据,然后通过 MediaSource 控制媒体片段加载缓存和播放。
分析过程参考:
通过调试技术,我理清了 b 站视频播放很快的原理
代码参考:
MDN - WebAPI - MediaSource
一步一步学习使用 MediaSource 实现动态媒体流
MediaSource
演示源代码 ,直接全贴出来,加载全部如下
javascript
<!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 Codec
var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {
var mediaSource = new MediaSource;
//console.log(mediaSource.readyState); // closed
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
console.error('Unsupported MIME type or codec: ', mimeCodec);
}
function sourceOpen (_) {
//console.log(this.readyState); // open
var 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>
播放过程中加载,如下
javascript
<!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 Codec
var 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); // closed
video.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 查看视频信息,官方下载
shell
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
shell
MP4Box -dash 7000 -rap -frag-rap test001.mp4
上面命令表示按每7秒间隔将视频分段,最后会生成 2 个文件,其中 mpd 文件内容是
通过 ffmpeg 格式化
shell
ffmpeg -i non_fragmented.mp4 -movflags frag_keyframe+empty_moov+default_base_moof fragmented.mp4
格式化之后的 MP4 文件在官方示例 bufferAll.html 中可以加载播放了
怎么格式化成分段视频待补充...
分段加载
HTTP 请求头中添加 Range 表示要获取视频哪一部分数据,同时也要修改响应头信息
后端代码
后端代码基于 SpringMVC
java
@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);
}
}
}
前端代码
在示例代码基础上实现拖动加载和播放时加载
html
<!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); // closed
video.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>
验证
最后验证一下视频播放和加载,正常