使用 MediaSource 实现视频边播放边加载效果

目录

背景

打开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 格式化

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>

验证

最后验证一下视频播放和加载,正常


相关推荐
灵感素材坊2 小时前
解锁音乐创作新技能:AI音乐网站的正确使用方式
人工智能·经验分享·音视频
modest —YBW2 小时前
视频大小怎么计算?视频码率是什么,构成视频清晰度的核心要素!
音视频
cuijiecheng20182 小时前
音视频入门基础:RTP专题(10)——FFmpeg源码中,解析RTP header的实现
ffmpeg·音视频
AI服务老曹3 小时前
运用先进的智能算法和优化模型,进行科学合理调度的智慧园区开源了
运维·人工智能·安全·开源·音视频
Macdo_cn6 小时前
My Metronome for Mac v1.4.2 我的节拍器 支持M、Intel芯片
macos·音视频
kiramario7 小时前
【结束】JS如何不通过input的onInputFileChange使用本地mp4文件并播放,nextjs下放入public文件的视频用video标签无法打开
开发语言·javascript·音视频
余~~185381628009 小时前
矩阵碰一碰发视频的后端源码技术,支持OEM
线性代数·矩阵·音视频
划水哥~10 小时前
高清下载油管视频到本地
音视频
Luke Ewin16 小时前
根据音频中的不同讲述人声音进行分离音频 | 基于ai的说话人声音分离项目
人工智能·python·音视频·语音识别·声纹识别·asr·3d-speaker
Macdo_cn1 天前
Infuse Pro for Mac v8.1 全能视频播放器 支持M、Intel芯片
macos·音视频