Media Source Extensions(MSE):彻底掌握 Web 流媒体的底层引擎
你在 B 站看视频,地址栏里是
blob:https://www.bilibili.com/xxxxxxxx,新标签页打开是空白。这个 blob URL 背后就是 MSE。hls.js、flv.js、Shaka Player 这些播放库的核心,也是 MSE。理解了 MSE,你就理解了现代 Web 视频播放的底层运作机制。这篇文章把 MSE 从原理到每一个 API 细节,完整地拆解清楚。
为什么需要 MSE?
原生 <video> 标签的能力有一个根本限制:它只能播放一个完整的媒体文件,数据来源只能是一个 URL。这意味着:
- 无法动态切换码率(自适应码率需要在运行时替换数据源)
- 无法实现真正的流式播放(直播内容是持续生成的,没有完整文件)
- 无法精细控制缓冲策略(浏览器自主决定缓存多少)
- 无法合并多个分片(HLS 的 TS 片段需要拼接后才能播放)
MSE(Media Source Extensions)是 W3C 的标准 API,它的核心思想是:把 <video> 的数据来源从"一个 URL"变成"一个由 JavaScript 动态控制的数据管道"。开发者可以随时往这个管道里注入任意的媒体数据,浏览器负责解码和渲染。
这就是为什么 hls.js 能在不支持 HLS 的 Chrome 上播放 m3u8——它用 Fetch 下载 TS 分片,在 JS 里解析,转成 fMP4 格式,然后通过 MSE 喂给 <video>。 juejin.cn developer.mozilla.org
第一部分:整体架构
MSE 的核心由三个层次构成:
JavaScript 层
├── MediaSource(数据管道的总控制器)
│ ├── SourceBuffer(视频轨道的数据缓冲区)
│ └── SourceBuffer(音频轨道的数据缓冲区)
└── URL.createObjectURL(mediaSource) → blob:// URL
浏览器媒体引擎层
├── 解封装(fMP4 → 视频帧 + 音频帧)
├── 解码(H.264 → YUV 像素数据)
└── 音视频同步渲染
<video> 元素
└── src = "blob://..."(指向 MediaSource 对象)
数据流向是单向的:
网络/AI生成 → ArrayBuffer → appendBuffer() → SourceBuffer
↓
浏览器解码渲染
↓
<video> 画面输出
第二部分:MediaSource 完整 API
readyState — MediaSource 的三种状态
MediaSource 有三个生命周期状态,理解它们是避免大量 InvalidStateError 的关键:
| 状态 | 含义 | 可执行操作 |
|---|---|---|
closed | 初始状态,未与任何 <video> 关联 | 只能调用 isTypeSupported() |
open | 已关联并激活,可以接收数据 | addSourceBuffer()、appendBuffer()、remove() |
ended | endOfStream() 被调用后 | 仍可读取,但不能追加新数据(除非重新触发 sourceopen) |
状态转换图:
closed ──(video.src = blobURL)──→ open ──(endOfStream())──→ ended
↑ | |
└──(video 被销毁/src 被替换)──────┘◄──(再次 appendBuffer)────┘
重要:当
readyState为ended时,如果再次调用appendBuffer()或remove(),MSE 会自动把状态切回open并重新触发sourceopen事件。如果你只监听了一次sourceopen,要注意这个行为。
MediaSource 核心方法
const mediaSource = new MediaSource();
// 1. 添加 SourceBuffer(只能在 readyState === 'open' 时调用)
const videoBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.64002A"');
const audioBuffer = mediaSource.addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');
// 2. 移除 SourceBuffer(移除前必须先 abort())
videoBuffer.abort();
mediaSource.removeSourceBuffer(videoBuffer);
// 3. 通知流结束(点播视频全部追加完毕后调用)
mediaSource.endOfStream();
// 4. 通知流结束并报错(触发 video 的 error 事件)
mediaSource.endOfStream('network'); // 或 'decode'
// 5. 设置直播的可跳转范围(直播场景)
mediaSource.setLiveSeekableRange(0, 300); // 允许在 0~300s 范围内跳转
mediaSource.clearLiveSeekableRange();
// 6. 静态方法:检测格式支持
MediaSource.isTypeSupported('video/mp4; codecs="avc1.64002A"'); // true/falseMediaSource 事件
// sourceopen:readyState 变为 open 时触发(最重要的事件)
// 必须在这里创建 SourceBuffer,不能在外面
mediaSource.addEventListener('sourceopen', () => {
console.log('MediaSource 已激活,readyState:', mediaSource.readyState); // 'open'
// 在这里 addSourceBuffer
}, { once: true }); // 注意:如果不用 once,需要处理重复触发
// sourceended:readyState 变为 ended 时触发
mediaSource.addEventListener('sourceended', () => {
console.log('流已结束');
});
// sourceclose:readyState 变为 closed 时触发
mediaSource.addEventListener('sourceclose', () => {
console.log('MediaSource 已关闭,通常是 video 被销毁');
});第三部分:SourceBuffer 完整 API
SourceBuffer 是真正接收数据的对象,也是 MSE 开发中最复杂的部分。
核心属性
updating — 最重要的状态属性,表示当前是否有 appendBuffer() 或 remove() 操作正在执行。在 updating === true 时调用 appendBuffer() 会直接抛出 InvalidStateError,这是 MSE 开发中最常见的错误。
// 错误写法:不检查 updating 就追加
sourceBuffer.appendBuffer(chunk1);
sourceBuffer.appendBuffer(chunk2); // 💥 InvalidStateError!上一个还没完成
// 正确写法:等待 updateend 再追加
sourceBuffer.appendBuffer(chunk1);
sourceBuffer.addEventListener('updateend', () => {
sourceBuffer.appendBuffer(chunk2);
}, { once: true });buffered — 返回 TimeRanges 对象,描述已追加到该 SourceBuffer 的数据范围。与 video.buffered 的区别是:video.buffered 是所有 SourceBuffer 的并集,而 sourceBuffer.buffered 只反映这一个缓冲区的状态。
timestampOffset — 时间戳偏移量,默认 0。这是处理时间戳不连续问题的关键工具(后文详述)。
appendWindowStart / appendWindowEnd — 定义一个时间窗口,只有时间戳落在 [appendWindowStart, appendWindowEnd] 范围内的帧才会被接受,范围外的帧会被静默丢弃。常用于精确裁剪视频片段。
mode 属性 — segments vs sequence
mode 决定了 SourceBuffer 如何处理追加的数据:
segments 模式(默认,适合点播):播放顺序由媒体数据中的时间戳决定。追加顺序可以乱序,浏览器按时间戳排序播放。适合点播场景,支持随机跳转。
sequence 模式(适合直播):播放顺序由追加顺序决定,时间戳被忽略(或被自动修正为连续值)。适合直播场景——直播数据的时间戳可能从某个很大的数字开始(如 Unix 时间戳),用 sequence 模式可以避免时间轴混乱。
// 直播场景:使用 sequence 模式
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.64002A"');
sourceBuffer.mode = 'sequence'; // 必须在第一次 appendBuffer 之前设置SourceBuffer 事件
sourceBuffer.addEventListener('updatestart', () => {
// appendBuffer() 或 remove() 开始执行
// updating 从 false → true
});
sourceBuffer.addEventListener('update', () => {
// 操作成功完成
// updating 从 true → false
});
sourceBuffer.addEventListener('updateend', () => {
// 操作结束(无论成功还是被 abort)
// 在 update 事件之后触发
// ✅ 这是判断"可以追加下一个分片"的正确时机
});
sourceBuffer.addEventListener('error', () => {
// appendBuffer() 执行时发生错误
// 通常是数据格式不正确
console.error('SourceBuffer 错误,可能是数据格式问题');
});
sourceBuffer.addEventListener('abort', () => {
// appendBuffer() 或 remove() 被 abort() 中断
});第四部分:完整的分片追加队列管理
MSE 开发中最核心的工程问题是如何安全、高效地管理分片追加队列。由于 appendBuffer 是异步的,必须排队执行,不能并发。
class SourceBufferQueue {
constructor(sourceBuffer) {
this.sb = sourceBuffer;
this.queue = [];
this.isProcessing = false;
// 每次操作完成后自动处理队列
this.sb.addEventListener('updateend', () => {
this.isProcessing = false;
this._processNext();
});
this.sb.addEventListener('error', (e) => {
console.error('SourceBuffer error:', e);
this.isProcessing = false;
// 可以选择清空队列或重试
});
}
// 公共接口:追加数据(自动排队)
append(arrayBuffer) {
return new Promise((resolve, reject) => {
this.queue.push({ type: 'append', data: arrayBuffer, resolve, reject });
this._processNext();
});
}
// 公共接口:移除数据(自动排队)
remove(start, end) {
return new Promise((resolve, reject) => {
this.queue.push({ type: 'remove', start, end, resolve, reject });
this._processNext();
});
}
_processNext() {
// 如果正在处理或队列为空,直接返回
if (this.isProcessing || this.queue.length === 0) return;
// 如果 SourceBuffer 正在更新(外部操作),等待
if (this.sb.updating) return;
const task = this.queue.shift();
this.isProcessing = true;
try {
if (task.type === 'append') {
this.sb.appendBuffer(task.data);
} else if (task.type === 'remove') {
this.sb.remove(task.start, task.end);
}
// 在 updateend 时 resolve
this.sb.addEventListener('updateend', () => task.resolve(), { once: true });
this.sb.addEventListener('error', () => task.reject(new Error('SourceBuffer error')), { once: true });
} catch (e) {
this.isProcessing = false;
task.reject(e);
if (e.name === 'QuotaExceededError') {
// 缓冲区满,需要先清理再重试
this.queue.unshift(task);
this._evictAndRetry();
}
}
}
_evictAndRetry() {
// 异步清理后重试
setTimeout(() => this._processNext(), 50);
}
}第五部分:缓冲区管理与内存控制
QuotaExceededError — 缓冲区满了
这是长时间播放(直播、长视频)中最常遇到的错误。浏览器对每个 SourceBuffer 的内存有限制(Chrome 通常约 150MB),超出后 appendBuffer() 会抛出 QuotaExceededError。
解决方案是定期调用 remove() 清理已播放的历史数据:
function evictBuffer(sourceBuffer, video, keepBehind = 30, keepAhead = 60) {
const currentTime = video.currentTime;
const buffered = sourceBuffer.buffered;
if (buffered.length === 0) return Promise.resolve();
const bufferStart = buffered.start(0);
const bufferEnd = buffered.end(buffered.length - 1);
// 计算需要清理的范围
const evictStart = bufferStart;
const evictEnd = Math.max(bufferStart, currentTime - keepBehind);
if (evictEnd <= evictStart) return Promise.resolve();
console.log(`清理缓冲区: ${evictStart.toFixed(1)}s ~ ${evictEnd.toFixed(1)}s`);
return new Promise((resolve) => {
sourceBuffer.remove(evictStart, evictEnd);
sourceBuffer.addEventListener('updateend', resolve, { once: true });
});
}
// 在 appendBuffer 抛出 QuotaExceededError 时调用
async function safeAppend(queue, sourceBuffer, video, data) {
try {
await queue.append(data);
} catch (e) {
if (e.name === 'QuotaExceededError') {
await evictBuffer(sourceBuffer, video);
await queue.append(data); // 清理后重试
} else {
throw e;
}
}
}缓冲区管理策略
不同场景需要不同的缓冲策略:
点播场景 — 激进预加载,保留所有已加载数据:
// 点播:尽量多缓冲,只清理当前位置 60s 之前的数据
const keepBehind = 60; // 保留已播放 60s 的数据(支持快速回退)
const keepAhead = 120; // 预加载未来 120s 的数据直播场景 — 保守策略,避免内存溢出:
// 直播:只保留少量历史,重点保证未来数据
const keepBehind = 10; // 只保留 10s 历史(直播不需要回退太多)
const keepAhead = 30; // 预加载 30s 未来数据AI 视频流式播放 — 平衡策略:
// AI 视频:生成多少追加多少,适当清理历史
const keepBehind = 20;
const keepAhead = Infinity; // 全部追加,不限制预加载第六部分:时间戳对齐与 timestampOffset
这是 MSE 开发中最容易出现的"隐形 Bug"——视频能播,但时间轴错乱,或者跳转后卡死。
问题根源:分片的时间戳不从 0 开始
很多视频分片的时间戳并不从 0 开始。比如第 3 个分片的时间戳可能是 12.000s ~ 18.000s,这没问题。但在以下场景中会出现时间戳不连续:
场景一:直播时间戳。直播分片的时间戳通常是服务器的 Unix 时间戳(如 1712000000s),直接追加会导致 video.duration 变成一个巨大的数字,进度条无法正常显示。
// 解决:用 timestampOffset 将时间戳归零
// 假设第一个分片的时间戳是 1712000000
const firstSegmentTimestamp = 1712000000;
sourceBuffer.timestampOffset = -firstSegmentTimestamp;
// 之后追加的数据时间戳会被自动减去这个偏移量场景二:码率切换后的时间戳跳跃。从 720p 切换到 1080p 时,新分片的时间戳可能与上一个分片的结束时间不完全对齐,导致缓冲区出现空洞(gap),触发 waiting 事件卡顿。
// 解决:切换码率时,将 timestampOffset 设置为当前播放位置
// 确保新分片的时间戳从当前播放点开始
sourceBuffer.timestampOffset = video.currentTime;场景三:sequence 模式下的时间戳管理。在 sequence 模式中,timestampOffset 会在每次 appendBuffer 后自动递增(增量等于追加的数据时长),开发者无需手动管理。
第七部分:音视频分离与同步
高质量的 MSE 实现通常将音频和视频分开管理,使用两个独立的 SourceBuffer:
mediaSource.addEventListener('sourceopen', async () => {
// 分别创建音频和视频 SourceBuffer
const videoSB = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.64002A"');
const audioSB = mediaSource.addSourceBuffer('audio/mp4; codecs="mp4a.40.2"');
const videoQueue = new SourceBufferQueue(videoSB);
const audioQueue = new SourceBufferQueue(audioSB);
// 分别追加初始化片段
const [videoInit, audioInit] = await Promise.all([
fetch('video_init.mp4').then(r => r.arrayBuffer()),
fetch('audio_init.mp4').then(r => r.arrayBuffer()),
]);
await Promise.all([
videoQueue.append(videoInit),
audioQueue.append(audioInit),
]);
// 并行追加媒体分片(浏览器负责音视频同步)
for (let i = 1; i <= totalSegments; i++) {
const [videoSeg, audioSeg] = await Promise.all([
fetch(`video_seg.m4s`).then(r => r.arrayBuffer()),
fetch(`audio_seg.m4s`).then(r => r.arrayBuffer()),
]);
await Promise.all([
videoQueue.append(videoSeg),
audioQueue.append(audioSeg),
]);
}
mediaSource.endOfStream();
});为什么要分离音视频?
- 可以独立控制音频和视频的缓冲策略
- 支持多音轨切换(不同语言的配音)
- 码率切换时只需替换视频轨,音频保持不变
- 更精细的内存管理(分别清理音频和视频缓冲区)
第八部分:实现 Seek(跳转)
在 MSE 场景下,用户跳转到未缓冲的位置时,需要手动处理:清空当前缓冲区,重新下载目标位置附近的数据。
async function handleSeek(video, videoQueue, videoSB, targetTime) {
// 1. 暂停播放
video.pause();
// 2. 等待 SourceBuffer 空闲
if (videoSB.updating) {
await new Promise(resolve =>
videoSB.addEventListener('updateend', resolve, { once: true })
);
}
// 3. 中止当前操作
videoSB.abort();
// 4. 清空整个缓冲区(或只清空目标位置前后的数据)
if (videoSB.buffered.length > 0) {
const start = videoSB.buffered.start(0);
const end = videoSB.buffered.end(videoSB.buffered.length - 1);
await videoQueue.remove(start, end);
}
// 5. 找到目标时间最近的关键帧(IDR 帧)位置
// 必须从关键帧开始解码,否则画面会花屏
const keyframeTime = findNearestKeyframe(targetTime);
// 6. 重新下载并追加目标位置的分片
const segmentIndex = Math.floor(keyframeTime / SEGMENT_DURATION);
const segData = await fetch(`seg.m4s`).then(r => r.arrayBuffer());
await videoQueue.append(segData);
// 7. 设置播放位置并恢复播放
video.currentTime = targetTime;
video.play();
}
// 监听 seeking 事件触发跳转处理
video.addEventListener('seeking', () => {
// 检查目标时间是否已在缓冲区内
const isBuffered = isTimeBuffered(videoSB, video.currentTime);
if (!isBuffered) {
handleSeek(video, videoQueue, videoSB, video.currentTime);
}
});
function isTimeBuffered(sourceBuffer, time) {
const buffered = sourceBuffer.buffered;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= time && time <= buffered.end(i)) {
return true;
}
}
return false;
}第九部分:常见错误与调试指南
错误速查表
| 错误 | 原因 | 解决方案 |
|---|---|---|
InvalidStateError: readyState is not 'open' | 在 sourceopen 之前调用了 addSourceBuffer() | 必须在 sourceopen 事件回调里创建 SourceBuffer |
InvalidStateError: updating is true | appendBuffer() 并发调用 | 等待 updateend 再追加,使用队列管理 |
QuotaExceededError | 缓冲区内存超限 | 调用 remove() 清理历史数据 |
NotSupportedError: MIME type not supported | 格式字符串不正确或浏览器不支持 | 先用 MediaSource.isTypeSupported() 检测 |
| 视频花屏 | 追加数据不从关键帧(IDR 帧)开始 | 确保每个分片的第一帧是关键帧 |
| 音视频不同步 | timestampOffset 设置错误 | 检查音频和视频的时间戳偏移量是否一致 |
| 跳转后卡死 | Seek 后没有清理缓冲区重新加载 | 实现完整的 Seek 处理逻辑 |
调试工具
Chrome 内置了强大的 MSE 调试工具,在地址栏输入 chrome://media-internals/ 可以看到:
- 每个
<video>元素的readyState、networkState SourceBuffer的buffered范围可视化- 解码器状态、丢帧信息
- 完整的媒体事件日志
// 代码层面的调试辅助
function debugMSE(mediaSource, sourceBuffer, video) {
const log = (msg) => console.log(`[MSE Debug] ${new Date().toISOString()} ${msg}`);
mediaSource.addEventListener('sourceopen', () => log('sourceopen'));
mediaSource.addEventListener('sourceended', () => log('sourceended'));
mediaSource.addEventListener('sourceclose', () => log('sourceclose'));
sourceBuffer.addEventListener('updatestart', () => log(`updatestart, buffered: ${getBufferedStr(sourceBuffer)}`));
sourceBuffer.addEventListener('updateend', () => log(`updateend, buffered: ${getBufferedStr(sourceBuffer)}`));
sourceBuffer.addEventListener('error', () => log('SourceBuffer ERROR'));
video.addEventListener('waiting', () => log(`video waiting, currentTime: ${video.currentTime.toFixed(2)}`));
video.addEventListener('stalled', () => log('video stalled'));
video.addEventListener('error', () => log(`video error: ${video.error?.message}`));
}
function getBufferedStr(sb) {
if (!sb.buffered.length) return '[]';
const ranges = [];
for (let i = 0; i < sb.buffered.length; i++) {
ranges.push(`${sb.buffered.start(i).toFixed(2)}~${sb.buffered.end(i).toFixed(2)}`);
}
return `[${ranges.join(', ')}]`;
}第十部分:MSE 与 hls.js 的关系
理解了 MSE,再看 hls.js 的源码就会豁然开朗。hls.js 本质上就是一个建立在 MSE 之上的状态机,它做了以下几件事:
hls.js 内部工作流程:
1. 下载并解析 m3u8(PlaylistLoader)
2. 根据 ABR 算法选择码率(AbrController)
3. 下载 TS 分片(FragmentLoader)
4. 解析 TS 容器,提取 H.264 和 AAC 数据(TSDemuxer)
5. 将 H.264/AAC 重新封装为 fMP4 格式(MP4Remuxer)
6. 通过 MSE appendBuffer() 追加到 SourceBuffer(StreamController)
7. 处理时间戳对齐、跳转、错误恢复(各种 Controller)
所以当你直接使用 hls.js 时,你其实是在用一个封装好的 MSE 管理器,它帮你处理了所有上面提到的复杂问题。理解 MSE 的底层,才能在 hls.js 出问题时知道从哪里入手调试。
API 完整速查表
| API | 类型 | 说明 |
|---|---|---|
new MediaSource() | 构造函数 | 创建 MSE 实例 |
mediaSource.readyState | 属性 | closed / open / ended |
mediaSource.duration | 属性(读写) | 媒体总时长 |
mediaSource.sourceBuffers | 属性(只读) | 所有 SourceBuffer 列表 |
mediaSource.addSourceBuffer(mime) | 方法 | 创建 SourceBuffer |
mediaSource.removeSourceBuffer(sb) | 方法 | 移除 SourceBuffer |
mediaSource.endOfStream() | 方法 | 通知数据结束 |
mediaSource.setLiveSeekableRange(s,e) | 方法 | 设置直播可跳转范围 |
MediaSource.isTypeSupported(mime) | 静态方法 | 检测格式支持 |
sourceBuffer.mode | 属性(读写) | segments / sequence |
sourceBuffer.updating | 属性(只读) | 是否正在更新 |
sourceBuffer.buffered | 属性(只读) | 已缓冲的 TimeRanges |
sourceBuffer.timestampOffset | 属性(读写) | 时间戳偏移量 |
sourceBuffer.appendWindowStart | 属性(读写) | 追加窗口起始时间 |
sourceBuffer.appendWindowEnd | 属性(读写) | 追加窗口结束时间 |
sourceBuffer.appendBuffer(data) | 方法 | 追加 ArrayBuffer 数据 |
sourceBuffer.remove(start, end) | 方法 | 移除指定范围的数据 |
sourceBuffer.abort() | 方法 | 中止当前操作 |
sourceBuffer.changeType(mime) | 方法 | 切换 MIME 类型(码率切换用) |
URL.createObjectURL(ms) | 工具方法 | 生成 blob:// URL |
URL.revokeObjectURL(url) | 工具方法 | 释放 blob:// URL 内存 |
第十一部分:生产级完整实现
把本章所有知识点整合成一个可用于生产的 MSE 播放器核心:
class MSEPlayer {
constructor(videoEl) {
this.video = videoEl;
this.mediaSource = new MediaSource();
this.videoSB = null;
this.audioSB = null;
this.videoQueue = [];
this.audioQueue = [];
this.isVideoUpdating = false;
this.isAudioUpdating = false;
this.totalDuration = 0;
// 绑定 MediaSource 到 video
this.video.src = URL.createObjectURL(this.mediaSource);
this.mediaSource.addEventListener('sourceopen', () => {
// sourceopen 后立即释放 blob URL,避免内存泄漏
URL.revokeObjectURL(this.video.src);
this._onSourceOpen();
}, { once: true });
// 监听 video 跳转事件
this.video.addEventListener('seeking', () => this._onSeeking());
}
_onSourceOpen() {
// 创建独立的音视频 SourceBuffer
this.videoSB = this.mediaSource.addSourceBuffer(
'video/mp4; codecs="avc1.64002A"'
);
this.audioSB = this.mediaSource.addSourceBuffer(
'audio/mp4; codecs="mp4a.40.2"'
);
// 绑定 updateend 事件驱动队列
this.videoSB.addEventListener('updateend', () => {
this.isVideoUpdating = false;
this._processVideoQueue();
});
this.audioSB.addEventListener('updateend', () => {
this.isAudioUpdating = false;
this._processAudioQueue();
});
// 错误处理
this.videoSB.addEventListener('error', (e) => {
console.error('[MSEPlayer] videoSB error:', e);
});
this.audioSB.addEventListener('error', (e) => {
console.error('[MSEPlayer] audioSB error:', e);
});
}
// ── 公共接口 ──────────────────────────────────────
appendVideoChunk(arrayBuffer) {
this.videoQueue.push(arrayBuffer);
this._processVideoQueue();
}
appendAudioChunk(arrayBuffer) {
this.audioQueue.push(arrayBuffer);
this._processAudioQueue();
}
setDuration(seconds) {
if (this.mediaSource.readyState === 'open') {
this.mediaSource.duration = seconds;
}
this.totalDuration = seconds;
}
finalize() {
const tryEnd = () => {
const videoIdle = !this.isVideoUpdating && this.videoQueue.length === 0;
const audioIdle = !this.isAudioUpdating && this.audioQueue.length === 0;
const sbIdle = !this.videoSB?.updating && !this.audioSB?.updating;
if (videoIdle && audioIdle && sbIdle) {
if (this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
console.log('[MSEPlayer] 流已结束');
}
} else {
setTimeout(tryEnd, 100);
}
};
tryEnd();
}
destroy() {
try {
if (this.videoSB?.updating) this.videoSB.abort();
if (this.audioSB?.updating) this.audioSB.abort();
if (this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
}
} catch (e) {
// 忽略销毁时的错误
}
this.video.src = '';
this.videoQueue = [];
this.audioQueue = [];
}
// ── 内部队列处理 ──────────────────────────────────
_processVideoQueue() {
if (this.isVideoUpdating) return;
if (!this.videoSB || this.videoSB.updating) return;
if (this.videoQueue.length === 0) return;
const chunk = this.videoQueue.shift();
this.isVideoUpdating = true;
try {
this.videoSB.appendBuffer(chunk);
} catch (e) {
this.isVideoUpdating = false;
if (e.name === 'QuotaExceededError') {
this.videoQueue.unshift(chunk); // 放回队列
this._evict(this.videoSB).then(() => this._processVideoQueue());
} else {
console.error('[MSEPlayer] appendBuffer video error:', e.name, e.message);
}
}
}
_processAudioQueue() {
if (this.isAudioUpdating) return;
if (!this.audioSB || this.audioSB.updating) return;
if (this.audioQueue.length === 0) return;
const chunk = this.audioQueue.shift();
this.isAudioUpdating = true;
try {
this.audioSB.appendBuffer(chunk);
} catch (e) {
this.isAudioUpdating = false;
if (e.name === 'QuotaExceededError') {
this.audioQueue.unshift(chunk);
this._evict(this.audioSB).then(() => this._processAudioQueue());
} else {
console.error('[MSEPlayer] appendBuffer audio error:', e.name, e.message);
}
}
}
// ── 缓冲区清理 ────────────────────────────────────
_evict(sourceBuffer, keepBehind = 30) {
return new Promise((resolve) => {
const currentTime = this.video.currentTime;
const buffered = sourceBuffer.buffered;
if (buffered.length === 0) return resolve();
const evictEnd = Math.max(
buffered.start(0),
currentTime - keepBehind
);
if (evictEnd <= buffered.start(0)) return resolve();
sourceBuffer.remove(buffered.start(0), evictEnd);
sourceBuffer.addEventListener('updateend', resolve, { once: true });
});
}
// ── Seek 处理 ─────────────────────────────────────
_onSeeking() {
const target = this.video.currentTime;
if (this._isBuffered(this.videoSB, target)) return; // 已缓冲,无需处理
// 触发外部的重新加载逻辑
this.onSeekToUnbuffered?.(target);
}
_isBuffered(sb, time) {
if (!sb) return false;
const buffered = sb.buffered;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= time && time <= buffered.end(i) + 0.1) {
return true;
}
}
return false;
}
}
// ── 使用示例 ──────────────────────────────────────────
const player = new MSEPlayer(document.querySelector('video'));
// 设置跳转到未缓冲区域时的回调
player.onSeekToUnbuffered = async (targetTime) => {
console.log('用户跳转到未缓冲位置:', targetTime);
// 在这里重新下载目标位置的分片
};
// 流式追加数据(AI 视频生成场景)
async function streamAIVideo(player) {
// 先追加初始化片段
const videoInit = await fetch('/init_video.mp4').then(r => r.arrayBuffer());
const audioInit = await fetch('/init_audio.mp4').then(r => r.arrayBuffer());
player.appendVideoChunk(videoInit);
player.appendAudioChunk(audioInit);
// 持续追加生成的媒体分片
const response = await fetch('/ai-generate-stream');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析分片类型(实际项目中通过协议头或单独通道区分)
if (isVideoSegment(value)) {
player.appendVideoChunk(value.buffer);
} else {
player.appendAudioChunk(value.buffer);
}
}
player.finalize();
}小结
MSE 的核心思路可以用一句话概括:用 JavaScript 接管媒体数据的供给,把浏览器变成一个可编程的解码渲染引擎。掌握它需要理解三个层次:
第一层是 API 层——MediaSource 的三种状态、SourceBuffer 的 updating 机制、appendBuffer 的异步本质。这是避免 InvalidStateError 的基础。
第二层是 工程层——队列管理、缓冲区清理、QuotaExceededError 处理、音视频分离。这是让 MSE 在生产环境稳定运行的关键。
第三层是 时序层——timestampOffset 的时间对齐、Seek 时的缓冲区重置、直播时间戳归零。这是解决"能播但不对劲"问题的核心。
hls.js、flv.js、Shaka Player 这些库本质上都是在这三个层次上做了完善的封装。理解了 MSE 的底层,你就能在这些库出问题时精准定位,也能在需要定制化场景时直接操控底层实现——比如 AI 视频的边生成边播放,就是 MSE 最直接的应用场景之一。
下一篇将深入移动端兼容性,把 iOS Safari 的 autoplay 限制、X5 内核的同层播放、Android 各机型的事件差异,以及页面切换续播等移动端特有的技术难点全面拆解。