背景
最近公司的一个项目,首页中用到了一段炫酷的mp4视频作为背景,一开始视频有点大,打开时间有点慢,后来直观的思维,视频需要压缩一下,小一点。设计人员也配合的很好,压缩了很多。但是转念一想,大视频就没辙了吗,于是调研了一下大视频的加载方案,我觉得无非就是两种,一种是把视频物理切割一下,变成好几个小视频,另一种就是分片加载。第一种我觉得每次手动切太麻烦,我想用第二种,分片加载的方法。
range
那么想到分片,第一个想到的肯定是range
字段。
range简介
首先来了解一下http请求头中的range
字段:
HTTP 的
Range
头字段用于指定客户端请求服务器发送指定范围的响应实体(例如,文件的一部分)。这在处理大文件或流媒体等情况下特别有用,因为客户端可以仅请求所需的部分数据,而不必下载整个文件。Range
头字段的格式如下:
js
Range: bytes=start-end
HTTP 的 Range
头字段用于指定客户端请求服务器发送指定范围的响应实体(例如,文件的一部分)。这在处理大文件或流媒体等情况下特别有用,因为客户端可以仅请求所需的部分数据,而不必下载整个文件。
Range
头字段的格式如下:
sql
Range: bytes=start-end
其中,start
和 end
表示字节范围的起始和结束位置。请注意,范围是从 0 开始的,而且是包含 start
字节但不包含 end
字节的。
*以上摘自chatgpt的回答
使用
那么在代码中如何应用,我们以fetch为例:
js
fetch(assetURL, {
headers: {
Range: `bytes=${start}-${end}`
},
})
就这么简单,具体的效果,我们在后文体现。
blob实现
既然了解了range
那其实就可以着手实现了。 但是再理一理思路,我们要拿到的是视频文件,并且是从后端接口返回的数据,不再是一个地址,不是直接从服务器去获取静态资源,那么我们从接口拿到的是一种什么格式的数据,然后我们如何渲染这个数据?
二进制数据
接口返回的视频数据应该是二进制流。
也就是说我们前端拿到的数据是一些二进制数据,而不再是我们熟悉的数组,字符串,对象什么的。
前端其实操作二进制数据的场景很少,大部分涉及到的场景都和各种文件有关系,比如说前端拿到后端返回的文件数据后,在前端做下载的操作,就需要利用那些二进制流生成文件。
那么明确了数据的格式后,我们应当如何操作这些二进制数据生成视频文件呢?
blob登场!!!
blob简介
blob表示
二进制大对象
,是JavaScript对不可修改二进制数据的封装类型。包含字符串的数组、ArrayBuffer、ArrayBufferViews,甚至其他Blob都可以用来创建blob。摘自《JavaScript高级程序设计》(第四版)
Blob
(Binary Large Object)是一种数据类型,表示一个不可变的、原始数据的类文件对象。它通常用于存储二进制数据,如图像、音频、视频文件,以及其他类似的数据。来自chatgpt的回答
用blob实现分片加载并同时播放
理论上来说,了解了range 和 blob这两个知识点之后,我们就清楚了如何获取分片的视频数据和如何处理获取的这些数据使纸成为视频,我们也就可以着手实现效果了。
那下面我们就开始实现吧!!!
后端接口
这里我们展示一下后端的koa2的接口,主要展示一下后端接口如何处理range字段。
js
router.get('/getFmp4', async (ctx) => {
const stat = fs.statSync(path.join(__dirname, 'fmp4.mp4'))
const range = ctx.req.headers.range
const parts = range.replace(/bytes=/, '').split('-')
const start = Number(parts[0])
const end = Number(parts[1]) || stat.size - 1
ctx.set('Content-Range', `bytes ${start}-${end}/${stat.size}`)
ctx.type = 'video/mp4'
ctx.set('Accept-Ranges', 'bytes')
ctx.status = 206
const stream = fs.createReadStream(path.join(__dirname, 'fmp4.mp4'), {
start,
end
})
ctx.body = stream
})
前端代码
主要还是看一下前端的实现。
js
const rangeVideo = () => {
const totalSize = 5524488
const chunkSize = 500000
const numChunks = Math.ceil(totalSize / chunkSize)
let chunk = new Blob()
let index = 0
send()
function send() {
if (index >= numChunks) return
const start = index * chunkSize
const end = Math.min(start + chunkSize - 1, totalSize - 1)
fetch('url', {
headers: { Range: `bytes=${start}-${end}` },
})
.then((response) => {
index++
return response.blob()
})
.then((blob) => {
send()
chunk = new Blob([chunk, blob], { type: 'video/mp4' })
const url = URL.createObjectURL(chunk)
const currentTime = video.currentTime
video.src = url
video.currentTime = currentTime
video.play()
})
}
}
代码的主要思路是:
- 先拿到这个文件的具体大小
- 然后定下来每次分片的大小
- 接着去做一个递归的请求去依次获取分片
- 然后把每一次拿到的数据按顺序拼起来
- 这样最终我们就能拿到所有的数据,并且在前端拼接成一个完整的视频数据
我们先来看看整体的效果
效果
先看一下最终的效果
再看一下整体的请求
看一下其中一个请求
可以看见整体和预期的差不多,又差不少,哈哈哈。
我们分析一下上面的代码和最终的效果
分析
请求顺序
上面代码用了递归来依次发送请求,而不是用循环来实现。 因为视频数据的拼接必须是严格安装顺序来的,顺序不对加载的时候会立刻出错。 请求的时候虽然前面的内容都是一样大小,但是网络的波动可能会导致接受时候顺序出错,所以这里采用了上一个请求结束才开始下一个请求的写法。
播放时间
这里是用作背景视频,所以要求自动播放,所以在代码里面,每获取一部分新的视频,就拼接起来,更新一下视频的地址,然后再重新设置一下播放的时间
URL.createObjectURL
看一下我们生成的视频的地址,他是一个对象URL,也叫Blob URL,是指引用存储再File或者Blob中数据的URL。
URL.createObjectURL函数返回的值是一个指向内存中地址的字符串。
在这里我们拿到Blob数据后,就是通过这个函数生成了对象URL,然后作为视频的src。
不足
这里虽然实现了分片加载和自动播放的功能,但是很明显,在每次赋值src的时候,视频会有刷新的动作,体验很不好,而且下面显示的时间会变,我们获取到多少视频,他的视频市场就会显示多少,这也不是很好。
所以就是说,有没有办法能解决?让他一下就知道有多久的播放时长并且播放不要刷新,丝滑一点,有没有这样的一种神通???
有!!!
那就是今天的第二个主角,mediaSource
mediaSource实现
mediaSource简介
MediaSource
是一个 Web API,用于在浏览器中动态生成媒体流,从而实现实时音频和视频流的播放。它允许您通过 JavaScript 代码来控制媒体数据的生成和传输,从而创建自定义的流媒体播放体验。主要用途之一是实现流媒体的逐段加载,这对于大型视频、直播等场景非常有用。通过
MediaSource
,您可以控制媒体片段的加载、缓冲和播放,从而实现更灵活的流媒体处理。来自chatgpt的回答
前端代码实现
这里的后端代码不用动,我们只需要改变一下前端代码就行,看代码。
js
const rangeVideo = () => {
const totalSize = 9350042
const chunkSize = 1000000
const numChunks = Math.ceil(totalSize / chunkSize)
let index = 0
const assetURL = 'url'
var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {
var mediaSource = new MediaSource()
video.src = URL.createObjectURL(mediaSource)
mediaSource.addEventListener('sourceopen', sourceOpen)
} else {
console.error('Unsupported MIME type or codec: ', mimeCodec)
}
function sourceOpen(e) {
var mediaSource = e.target
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec)
const send = () => {
if (index >= numChunks) {
sourceBuffer.addEventListener('updateend', function (_) {
mediaSource.endOfStream()
})
} else {
const start = index * chunkSize
const end = Math.min(start + chunkSize - 1, totalSize - 1)
fetch(assetURL, {
headers: {
Range: `bytes=${start}-${end}`,
responseType: 'arraybuffer',
},
}).then(async (response) => {
response = await response.arrayBuffer()
index++
sourceBuffer.appendBuffer(response)
send()
video.play()
})
}
}
send()
}
}
可以看见代码的整体思路还是一样的,只是多了一下mediaSource的事件,还有数据的拼接方式变了,请求的发送还是一样的代码。
效果
丝滑,完美!!!
这效果,说实话已经和我预想中的一模一样了。
没有一点卡顿,没有一点不好的感觉!!!
分析
这里主要是对mediaSource的分析。
- 我们首先创建了一个
MediaSource
对象,并将其 URL 赋给了<video>
元素的src
属性 - 在
sourceopen
事件中,我们添加了一个SourceBuffer
对象,用于加载视频片段数据 - 最后,我们通过
fetch
获取视频片段数据,将其添加到SourceBuffer
中,然后开始播放视频
MediaSource的readyState属性表示了其状态,分别有closed,open,ended三种
- closed:MS没有和媒体元素如video相关联。MS刚创建时就是该状态。
- open:source打开,并且准备接受通过sourceBuffer.appendBuffer添加的数据。
- ended:当endOfStream()执行完成,会变为该状态。
不足
这么完美的效果也有不足的地方吗?
很遗憾的说,确实有,那就是不是随便什么mp4视频都支持这样做的。
mediaSource MDN里面有一段例子代码的 MDN 我一开始就把这个代码跑出来,然后用了自己的一个mp4视频,结果报错:
Uncaught DOMException: Failed to execute 'endOfStream' on 'MediaSource': The MediaSource's readyState is not 'open'.
上网找了很多资料才找到了答案,就在下面的参考文章里,不是什么mp4都支持这样玩的,得是 fragment mp4,简称fmp4,普通的不行。 我用的例子就是从网上找到的fmp4资源,可用的视频链接,需要的可以自行下载。
fragment mp4
那么到了这里,我们只需要把普通的mp4视频转化成fmp4,不就解决了所有的问题。那么如何操作,这里给出两个资源。
2. ffmpeg 使用方法见参考文章4