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初始状态,未与任何 &lt;video&gt; 关联只能调用 isTypeSupported()
open已关联并激活,可以接收数据addSourceBuffer()appendBuffer()remove()
endedendOfStream() 被调用后仍可读取,但不能追加新数据(除非重新触发 sourceopen

状态转换图:

closed ──(video.src = blobURL)──→ open ──(endOfStream())──→ ended
  ↑                                 |                          |
  └──(video 被销毁/src 被替换)──────┘◄──(再次 appendBuffer)────┘

重要:当 readyStateended 时,如果再次调用 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/false

MediaSource 事件

// 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();
});

为什么要分离音视频?

  • 可以独立控制音频和视频的缓冲策略
  • 支持多音轨切换(不同语言的配音)
  • 码率切换时只需替换视频轨,音频保持不变
  • 更精细的内存管理(分别清理音频和视频缓冲区)

zhuanlan.zhihu.com


第八部分:实现 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 trueappendBuffer() 并发调用等待 updateend 再追加,使用队列管理
QuotaExceededError缓冲区内存超限调用 remove() 清理历史数据
NotSupportedError: MIME type not supported格式字符串不正确或浏览器不支持先用 MediaSource.isTypeSupported() 检测
视频花屏追加数据不从关键帧(IDR 帧)开始确保每个分片的第一帧是关键帧
音视频不同步timestampOffset 设置错误检查音频和视频的时间戳偏移量是否一致
跳转后卡死Seek 后没有清理缓冲区重新加载实现完整的 Seek 处理逻辑

调试工具

Chrome 内置了强大的 MSE 调试工具,在地址栏输入 chrome://media-internals/ 可以看到:

  • 每个 &lt;video&gt; 元素的 readyStatenetworkState
  • SourceBufferbuffered 范围可视化
  • 解码器状态、丢帧信息
  • 完整的媒体事件日志
// 代码层面的调试辅助
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 的三种状态、SourceBufferupdating 机制、appendBuffer 的异步本质。这是避免 InvalidStateError 的基础。

第二层是 工程层——队列管理、缓冲区清理、QuotaExceededError 处理、音视频分离。这是让 MSE 在生产环境稳定运行的关键。

第三层是 时序层——timestampOffset 的时间对齐、Seek 时的缓冲区重置、直播时间戳归零。这是解决"能播但不对劲"问题的核心。

hls.js、flv.js、Shaka Player 这些库本质上都是在这三个层次上做了完善的封装。理解了 MSE 的底层,你就能在这些库出问题时精准定位,也能在需要定制化场景时直接操控底层实现——比如 AI 视频的边生成边播放,就是 MSE 最直接的应用场景之一。

下一篇将深入移动端兼容性,把 iOS Safari 的 autoplay 限制、X5 内核的同层播放、Android 各机型的事件差异,以及页面切换续播等移动端特有的技术难点全面拆解。