导读

看到网上有采用MediaSource实践视频缓冲区的,我也抱着试一试的态度实践了一下,但是却有许多过不去的坎,特此记录。

首先,可以从MDN官网看到介绍,其可以附着在媒体元素上控制播放和资源加载。

例如,可以和<video>搭配:

1
2
3
4
5
6
<video></video>
<script>
const video = document.getElementByTag("video");
const mediaSource = new MediaSource()
video.src = URL.createObjectURL(mediaSource)
</script>

这将创建一个本地的blobURL,传给video.src:

image-20250114214816540

其后可通过addSourceBuffer(mimeType)创建一个SourceBuffer用于后续的操作。

这里可以欣赏github上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!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>

可以看到,其能力就是自定义请求媒体资源实现了加载,而自定义请求,则可以完全一些权限认证的操作。

mimeCodec

可以通过安装mp4box了解媒体文件的编码相关信息。

首先安装mp4box

1
brew install mp4box

通过mp4box -info xxx查看

image-20250114220750253

因此有

1
2
3
var mimeCodec = 'video/mp4; codecs="avc1.640028, mp4a.40.2"';
...
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);

使用MediaSource 做视频缓冲

打开bilibili的一个视频,就可以从其进度条看到其在播放视频时总是预先加载部分视频,而不是整个加载或者播放时才加载,当暂停播放后加载到固定长度停止继续加载。

github上提供了类似的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<!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>

可以看到,其利用了HTTP协议的请求首部Range,并配合Content-Length响应头实现了对资源的部分请求。

MediaSourcesourceopen事件回调中,递归请求资源并播放,实现了简单的缓冲设置。

自己尝试时发现的问题

这个过程涉及安装的一些库

ffmpeg

1
brew install ffmpeg

当我自己尝试这个示例时,我使用从bilibili下载了这个视频,直接利用github的示例,发现一个报错

1
异常:InvalidStateError: Failed to read the 'buffered' property from 'SourceBuffer': This SourceBuffer has been removed from the parent media source. at SourceBuffer.invokeGetter

image-20250114222131094

是由sourceBuffere.appendBuffer()方法引起,这似乎是需要使用ffmpeg对视频转换处理

1
ffmpeg -i bilibili.mp4 -c:v copy -c:a copy -movflags frag_keyframe+empty_moov output.mp4

浏览器上能显示进度条但是无法加载视频,无画面无声音。

image-20250114222511475

控制台上的主要报错为:

image-20250114222604278

当下还没找到原因,后续有时间继续探索

参考文章

  1. 利用MediaSource做分片加载
  2. 同上文,但是做了class的封装,代码更友好
  3. ffmpeg 实现mp4视频转换
  4. 微软官网的一个使用mp4box的例子
  5. 这篇提到了自适应网速的逻辑
  6. ffmpeg官网