简介
近期有个需求要求在瀑布流里随着滚动播放不同的视频,由于原生的视频资源只有preload参数可以简易的控制下载,为了实现更细致化的视频下载控制,需要实现视频分片加载的功能。所以,让我们来踩坑吧!
技术调研
初步方向调研
- html原生video标签preload策略,只有基础的metadata,auto,none三种模式,无法精准控制。
- HLS流媒体播放协议。HLS是苹果公司制定的标准。视频在服务器进行分段和创建索引,通过m3u8文件传递视频播放所需信息。浏览器支持更佳。
- DASH流媒体播放协议。DASH是由MPEG组织制定的标准。视频在服务器进行分段和创建索引,通过mpd文件传递视频播放所需信息。需利用MSE(Media Source Extension)额外开发支持(IOS不支持)。youtube、bilibili等公司都是使用这个协议。
- 通过HTTP-Range与MSE获取视频分片并拼接播放。不需要服务端支持。
经过探讨,在考虑到减少服务端视频资源加载的情况下(视频是存储在CDN中),决定采用第4种方式,直接由前端控制分片。
MSE介绍
Media Source Extensions API简称MSE。MSE 使我们可以把通常的单个媒体文件的 src 值替换成引用 MediaSource 对象(一个包含即将播放的媒体文件的准备状态等信息的容器),以及引用多个 SourceBuffer 对象(代表多个组成整个串流的不同媒体块)的元素。MSE 让我们能够根据内容获取的大小和频率,或是内存占用详情(例如什么时候缓存被回收),进行更加精准地控制。它是基于它可扩展的 API 建立自适应比特率流客户端(例如 DASH 或 HLS 的客户端)的基础。

基础MP4结构介绍
MP4的容器格式是由一系列的 Box 组成。Box 是容器格式的基本组成单位,每个 Box 分为两部分,一部分是 Box Header,另一部分是 Box Body。Box 是可以嵌套的。常见的第一层 Box 类型,包括 ftyp,free,moov,mdat等。 moov Box 用于存储媒体的元数据,有且仅有一个,顺序是不固定的,但一般推进放在 mdat Box 之前。在播放媒体文件时,通常需要先解析moov Box来获取媒体的基本信息,然后再根据这些信息进行媒体的解码和播放操作。
moov 后置的情况,会导致网页播放视频时,需要先下载完整个视频文件,才能开始播放。现在的浏览器都支持智能地从尾部读取 moov 数据。但我们自己分片的情况下就需要自行请求尾部的moov数据。

Fragmented MP4介绍
Fragmented MP4(FMP4)是一种优化的 MP4 文件格式,特别适用于流媒体和逐步加载的场景。它通过将媒体数据分割成多个片段(fragment),每个片段包含独立的元数据和媒体数据,从而提高了播放的灵活性和效率。 使用MSE播放分片的视频,就需要视频属于fmp4。
MSE播放普通分片视频会报错,所以我们需要引入mp4box.js对加载的视频分片进行处理

mp4box库介绍
mp4box.js是一个用于处理MP4文件的工具库,通过这个库可以解析MP4文件,提前加载moov后置的视频、处理分片数据,转化普通的Mp4分片数据为fmp4资源,以便可以边下边播。
基础分片加载流程
- 通过MediaSource创建链接,并绑定到video中
- 先请求第一块分片,分片通过MediaSource.
- 判断第一块分片中是否包含moov(mp4box.ready事件监听)
- 未包含moov需要再单独请求moov数据(mp4file.appendBuffer返回)
- 当moov数据加载完后需要再回到原剩余加载位置(mp4box.seek方法获取位置)
- 加载剩余分片资源(本需求中只有播放过的视频资源需要进行加载)
完整加载逻辑如下图
代码参考
js
import { Downloader } from "./downloader.js";
const url = "videoLink"
;
function loadMediaData(ctx) {
const { dw, mp4file } = ctx.shared;
dw.chunkStart = mp4file.seek(0, true).offset;
mp4file.start();
dw.resume();
}
function handleUpdateEnd(ctx) {
console.log("upd", ctx.id, ctx.sb.updating, ctx.isEof, {...ctx});
if (ctx.sb.updating || ctx.shared.ms.readyState !== "open") return;
const seg = ctx.pending.shift();
if (seg && seg.isInit) {
ctx.shared.pendingInitCnt--;
}
console.log('pendingInitCnt', ctx.shared.pendingInitCnt, ctx.shared.loading)
if (ctx.shared.pendingInitCnt === 0 && !ctx.shared.loading) {
ctx.shared.loading = true;
loadMediaData(ctx);
return;
}
if (ctx.isEof && ctx.shared.notEndCnt) {
ctx.shared.notEndCnt--;
}
if (ctx.shared.notEndCnt === 0 && !ctx.shared.isMseEnd) {
if (ctx.sampleNum) {
ctx.shared.mp4file.releaseUsedSamples(ctx.id, ctx.sampleNum);
ctx.sampleNum = null;
}
// 检查所有 SourceBuffer 是否都完成更新
const sourceBuffers = ctx.shared.ms.sourceBuffers;
let allBuffersUpdated = true;
for (let i = 0; i < sourceBuffers.length; i++) {
if (sourceBuffers[i].updating) {
allBuffersUpdated = false;
break;
}
}
if (allBuffersUpdated && ctx.shared.ms.readyState === 'open' && !ctx.pending?.length && !seg) {
ctx.shared.isMseEnd = true;
console.log('endOfStream');
ctx.shared.ms.endOfStream();
}
}
if (seg && !seg.isInit) {
ctx.sampleNum = seg.sampleNum;
ctx.isEof = seg.isEnd;
console.log("appendBuffer", seg);
ctx.sb.appendBuffer(seg.buffer);
}
}
function linkMsAndMp4(vElem, ms, mp4file, mp4info) {
const trackLen = mp4info.tracks.length;
const shared = {
ms,
vElem,
loading: false,
notEndCnt: trackLen,
pendingInitCnt: trackLen,
dw: mp4file.dw,
mp4file,
loading: false,
isMseEnd: false
};
mp4info.tracks.forEach(track => {
setSegmentOptions(ms, mp4file, track, shared);
});
}
function setSegmentOptions(ms, mp4file, track, shared) {
const mime = `video/mp4; codecs="${track.codec}"`;
if (!MediaSource.isTypeSupported(mime)) {
throw new Error("MSE does not support: " + mime);
}
const sb = ms.addSourceBuffer(mime);
const ctx = {
sb,
id: track.id,
pending: [],
shared
};
sb.addEventListener("error", e => console.error(e));
sb.addEventListener("updateend", () => {
console.log("updateend", { ...ctx });
handleUpdateEnd(ctx)});
mp4file.setSegmentOptions(track.id, ctx, { nbSamples: 100 });
}
function initializeSegmentation(mp4file) {
mp4file.initializeSegmentation().forEach(seg => {
const ctx = seg.user;
console.log(seg);
ctx.sb.appendBuffer(seg.buffer);
ctx.pending.push({ isInit: true });
});
}
function handleSourceOpen(evt, vElem) {
URL.revokeObjectURL(evt.target.src);
console.log("handleSourceOpen");
const ms = evt.target;
const mp4file = MP4Box.createFile();
mp4file.onReady = info => {
console.log("mp4file is ready: ", info);
ms.duration = info.duration / info.timescale;
linkMsAndMp4(vElem, ms, mp4file, info);
initializeSegmentation(mp4file);
};
mp4file.onSegment = function(id, user, buffer, sampleNum, isLast) {
console.log("onSegment", { id, user, buffer, sampleNum, isLast });
const ctx = user;
ctx.pending.push({
id: id,
buffer: buffer,
sampleNum: sampleNum,
isEnd: isLast
});
handleUpdateEnd(ctx);
};
mp4file.onSamples = function (id, user, samples) {
console.log("Received "+samples.length+" samples on track "+id+" for object "+user);
}
const dw = (mp4file.dw = new Downloader({
url,
chunkSize: 180 * 1024,
onChunk({ bytes, isEof }) {
const next = mp4file.appendBuffer(bytes, isEof);
console.log("onChunk player status", vElem.readyState, isEof);
if (isEof) {
mp4file.flush();
} else {
console.log('next', next)
dw.chunkStart = next;
}
}
}));
mp4file.dw.start();
}
function attachMediaSource(vElem) {
const ms = new MediaSource();
ms.addEventListener("sourceopen", evt => handleSourceOpen(evt, vElem));
vElem.src = URL.createObjectURL(ms);
vElem.addEventListener('canplay', () => {
console.log('canplay')
vElem.play()
})
}
function bootstrap() {
if (!window.MediaSource) throw new Error("Browser does not support MSE");
const vElem = document.querySelector("video");
attachMediaSource(vElem);
}
document.addEventListener("DOMContentLoaded", bootstrap);
注意事项
CORS:
分片请求会触发CORS,所以需要运维支持跨域请求。且支持获取返回头的Content-Range,用于统计加载大小。
js播放/自动播放
由于浏览器限制,代码触发视频播放时必须设置muted属性。

预加载连续加载多分片后才能播放
前面部分的视频时获取HTMLMediaElement.readyState为1,连续加载多分片后,才变成4,没有中间2、3状态。而1状态无法播放。
通过查询得知跟关键帧有关,最后在调整setSegmentOptions(track.id, ctx, { nbSamples: 100 })
增多关键帧解决问题。
总结
由于需求的特殊化,使用了比较hack的方式:通过前端分片并解析视频的方式实现了视频的加载控制,但是一路踩了很多坑,包括mp4box版本变更语法不同、浏览器支持等等的问题。所以在有条件的情况下,还是更建议在目前更为主流的dash或hls方案。
本文仅简单介绍了初步实现视频分片播放的逻辑与原理,还有一部分网络、下载、视频变更播放进度等功能未处理,下次我们将继续完善分片视频组件。
参考
juejin.cn/post/684490... github.com/hsiaosiyuan... www.bilibili.com/opus/147500... developer.mozilla.org/en-US/docs/... gpac.github.io/mp4box.js/ nickdesaulniers.github.io/mp4info/ juejin.cn/post/727593...