最近在实现仿b站的项目时学习到视频流的知识特此来记录一下。
介绍
主流的视频平台使用的Http视频流大部分为DASH流和HLS流,而DASH主要在PC端使用,HLS主要在移动端以及直播方面使用。DASH不能像 HLS 在 Safari 上那样直接通过 <video>
播放,大多数浏览器需要额外的 JavaScript 处理,本文章以b站的视频流为例来实现原生Js处理DASH视频流实现PC浏览器播放。
前提
浏览器需要支持Media Source Extensions,MacOS以及IOS不支持。
正文
一个DASH文件包含一个头部用于存储帧信息以及.m4s文件,通过控制台抓取请求我们可以发现b站的视频通常都是请求.m4s文件获取资源。
通过过滤器筛选出所有请求
Range用于请求头可以告知服务器返回文件的哪一部分,b站视频通过配置Range来将整个资源分片请求,这就是为什么视频加载和跳转很快的原因。服务器响应时会带上Content-Range响应头来表示返回的部分在整个文件中的范围。
这时候我们就可以构建一个MediaSource()
对象实例SourceBuffer
,通过appendBuffer方法加载资源
代码
mimeCodec
指的是 MIME 类型 + 编解码格式 的字符串,可以从后端返回的视频信息中获得,用于检查浏览器是否支持该编解码格式,确保流式播放时解码正确。
我们可以从视频信息中获取DASH头信息包括initialization和index_range,其中index_range记录了关键帧在文件中的位置,可以通过解析index_range返回的数据来获取整个文件的分片信息。
一个 video
元素可以同时使用多个 SourceBuffer
,我们可以同时加载视频流与音频流。
ini
const mimeCodec = `${videosStreamInfo[i].mimeType}; codecs=${videosStreamInfo[i].codecs}`;
let audioMimeCodec = '';
let audioStreamUrl = '';
let audioInitialization = '';
let audioIndexRange = '';
if(audioStreamInfo) {
audioMimeCodec = `${audioStreamInfo.mimeType}; codecs=${audioStreamInfo.codecs}`;
audioStreamUrl = formatVideoUrl(audioStreamInfo.baseUrl);
audioInitialization = audioStreamInfo.SegmentBase.Initialization;
audioIndexRange = audioStreamInfo.SegmentBase.indexRange;
videoItem.audioLastSegmentIndex = 0;
videoItem.audioStartRange = 0;
};
// 检查浏览器是否支持该类型编码器
if(MediaSource.isTypeSupported(mimeCodec)) {
const mediaSource = new MediaSource();
const videoStreamUrl = formatVideoUrl(videosStreamInfo[i].baseUrl);
// 在 DASH 方式下,关键帧信息移到了 sidx box 里
const videoInitialization = videosStreamInfo[i].SegmentBase.Initialization;
const videoIndexRange = videosStreamInfo[i].SegmentBase.indexRange;
// 所有信息存在实例上停止播放不会消失
videoItem.mediaSource = mediaSource;
// 如果 MediaSource 被关闭(即 mediaSource.readyState 变为 closed 或 ended),它关联的 blob URL 将会失效
videoItem.videoSrc = URL.createObjectURL(mediaSource);
videoBlobUrls.value.push(videoItem.videoSrc);
// 支持编码的格式索引
videoItem.videoSupportIndex = i;
videoItem.videoLastSegmentIndex = 0;
videoItem.videoStartRange = Number(videoIndexRange.split('-')[1]) + 1;
mediaSource.addEventListener('sourceopen', sourceOpen);
通过index记录当前分片的索引值,在每次加载后自增,再根据索引获取当前关键帧的大小来构造Range就可以持续请求。
ini
async function loadChunk(sourceBuffer: SourceBuffer, videoStreamUrl: string, lastSegments: Segment[], type: string, isJump: boolean = false) {
if (!sourceBuffer.updating && videoItem.mediaSource.readyState === 'open' && (shouldLoadNextSegment() || isJump)) {
let lastSegmentIndex;
let range = '';
if(type === 'video') {
lastSegmentIndex = videoItem.videoLastSegmentIndex;
if(lastSegmentIndex < lastSegments.length)
range = `${videoItem.videoStartRange}-${videoItem.videoStartRange + lastSegments[lastSegmentIndex].referencedSize - 1}`;
}
else if(type === 'audio') {
lastSegmentIndex = videoItem.audioLastSegmentIndex;
if(lastSegmentIndex < lastSegments.length)
range = `${videoItem.audioStartRange}-${videoItem.audioStartRange + lastSegments[lastSegmentIndex].referencedSize - 1}`;
}
else return;
if(lastSegmentIndex < lastSegments.length) {
// 加载片段到 SourceBuffer
const arrayBuffer = await fetchVideoArrayBuffer(videoStreamUrl, range);
await waitSourceBuffer(sourceBuffer);
await insertStream(videoRef.value, sourceBuffer, arrayBuffer);
handleLoadChunk(lastSegments, type);
}
else {
// 当所有片段加载完成后,结束流
// 无法确定是否会再次播放片段不要调用endOfStream
// videoItem.mediaSource.endOfStream();
videoRef.value.removeEventListener('timeupdate', videoItem.handleTimeUpdate);
};
};
};
function handleLoadChunk(lastSegments: Segment[] = [], type: string): void {
if(type === 'video') {
videoItem.videoStartRange = videoItem.videoStartRange + lastSegments[videoItem.videoLastSegmentIndex].referencedSize;
videoItem.videoLastSegmentIndex++;
}
else if(type === 'audio') {
videoItem.audioStartRange = videoItem.audioStartRange + lastSegments[videoItem.audioLastSegmentIndex].referencedSize;
videoItem.audioLastSegmentIndex++;
}
else return;
};
function insertStream(mediaElement: HTMLMediaElement ,sourceBuffer: SourceBuffer, arrayBuffer: ArrayBuffer) {
return new Promise(resolve => {
try {
if (mediaElement.error) {
console.error('Media Error:', mediaElement.error);
return;
};
sourceBuffer.addEventListener('error', (event: Event) => {
console.error('append err:', event);
});
sourceBuffer.addEventListener('updateend', function updateListener() {
sourceBuffer.removeEventListener('updateend', updateListener);
resolve(arrayBuffer);
}, { once: true });
sourceBuffer.appendBuffer(arrayBuffer);
} catch(err) {
console.log('insert err', err);
};
});
};
可以通过buffered属性获取已缓冲的长度,自定义删除已播放的内容,减少内存占用。
javascript
function clearSourceBuffer(sourceBuffer: SourceBuffer) {
return new Promise<void>((resolve) => {
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const start = buffered.start(0);
const end = buffered.end(buffered.length - 1);
// 不会清除关键元数据
sourceBuffer.remove(start, end);
// 完成删除后退出
sourceBuffer.addEventListener('updateend', () => resolve(), { once: true });
} else {
resolve();
};
});
};
以上就是全部内容了,全部代码可在github.com/haodadadada... 拉取,小老哥如果感兴趣可以点个star,小弟万分感谢。第一次发文如果有问题可以在评论区指出。