媒体来源扩展

François Beaufort
François Beaufort
Joe Medley
Joe Medley

Media Source Extensions (MSE) 是一个 JavaScript API,可让您构建用于从音频或视频片段播放的串流。虽然本文未介绍,但如果您想在自己的网站中嵌入视频,以执行以下操作,则需要了解 MSE:

  • 自适应流式传输,换句话说就是根据设备功能和网络状况进行调整
  • 自适应拼接,例如广告插入
  • 时移
  • 控制性能和下载大小
基本 MSE 数据流
图 1:基本 MSE 数据流

您可以将 MSE 视为一个链条。如图所示,下载的文件和媒体元素之间有多个层。

  • 用于播放媒体的 <audio><video> 元素。
  • 包含 SourceBufferMediaSource 实例,用于为媒体元素提供数据。
  • 用于检索 Response 对象中的媒体数据的 fetch() 或 XHR 调用。
  • 调用 Response.arrayBuffer() 以馈送 MediaSource.SourceBuffer

在实际中,该链如下所示:

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function (response) {
      return response.arrayBuffer();
    })
    .then(function (arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function (e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

如果您能从上述说明中找到问题的答案,可以随时停止阅读。如需更详细的说明,请继续阅读。 我将通过构建一个基本的 MSE 示例来介绍此链条。每个构建步骤都会向上一步添加代码。

关于清晰度的说明

本文是否介绍了您在网页上播放媒体时需要了解的所有信息?不会,它只是为了帮助您理解您在其他地方可能遇到的更复杂的代码。为清楚起见,本文档简化了许多内容,并排除了许多内容。我们认为可以允许这种做法,因为我们还建议使用 Google 的 Shaka Player 等库。在整个过程中,我会指出哪些地方是故意简化了。

不涵盖的几项事项

以下是我不会介绍的一些内容(排名不分先后)。

  • 播放控件。我们可以使用 HTML5 <audio><video> 元素免费获得这些功能。
  • 错误处理。

适用于生产环境

以下是在生产环境中使用 MSE 相关 API 时建议注意的一些事项:

  • 在对这些 API 进行调用之前,请处理所有错误事件或 API 异常,并检查 HTMLMediaElement.readyStateMediaSource.readyState。在关联的事件传送之前,这些值可能会发生变化。
  • 在更新 SourceBuffermodetimestampOffsetappendWindowStartappendWindowEnd 或对 SourceBuffer 调用 appendBuffer()remove() 之前,请检查 SourceBuffer.updating 布尔值,确保之前的 appendBuffer()remove() 调用尚未完成。
  • 对于添加到 MediaSource 的所有 SourceBuffer 实例,请确保在调用 MediaSource.endOfStream() 或更新 MediaSource.duration 之前,其 updating 值均不为 true。
  • 如果 MediaSource.readyState 值为 ended,则 appendBuffer()remove() 等调用或设置 SourceBuffer.modeSourceBuffer.timestampOffset 会导致此值转换为 open。这意味着,您应做好处理多个 sourceopen 事件的准备。
  • 在处理 HTMLMediaElement error 事件时,MediaError.message 的内容对于确定失败的根本原因非常有用,尤其是对于在测试环境中难以重现的错误。

将 MediaSource 实例附加到媒体元素

与当今 Web 开发中的许多方面一样,您需要先从特征检测开始。接下来,获取媒体元素(<audio><video> 元素)。最后,创建 MediaSource 的实例。它会转换为网址,并传递给媒体元素的 source 属性。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  // Is the MediaSource instance ready?
} else {
  console.log('The Media Source Extensions API is not supported.');
}
将来源属性作为 blob 进行处理
图 1:作为 blob 的来源属性

MediaSource 对象可以传递给 src 属性可能看起来有点奇怪。它们通常是字符串,但也可能是 Blob。如果您检查包含嵌入式媒体的网页并查看其媒体元素,就会明白我的意思。

MediaSource 实例是否已准备就绪?

URL.createObjectURL() 本身是同步的;不过,它会异步处理附件。这会导致您在对 MediaSource 实例执行任何操作之前出现轻微延迟。幸运的是,您可以通过一些方法来测试这一点。最简单的方法是使用名为 readyStateMediaSource 属性。readyState 属性描述了 MediaSource 实例与媒体元素之间的关系。具体状态可以是以下值之一:

  • closed - MediaSource 实例未附加到媒体元素。
  • open - MediaSource 实例已附加到媒体元素,并且已准备好接收数据或正在接收数据。
  • ended - MediaSource 实例已附加到媒体元素,并且其所有数据均已传递给该元素。

直接查询这些选项可能会对性能产生不利影响。幸运的是,MediaSourcereadyState 发生变化时也会触发事件,具体而言是 sourceopensourceclosedsourceended。在构建的示例中,我将使用 sourceopen 事件来告知何时提取和缓冲视频。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  <strong>mediaSource.addEventListener('sourceopen', sourceOpen);</strong>
} else {
  console.log("The Media Source Extensions API is not supported.")
}

<strong>function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  // Create a SourceBuffer and get the media file.
}</strong>

请注意,我还调用了 revokeObjectURL()。我知道这似乎为时过早,但在媒体元素的 src 属性连接到 MediaSource 实例后,我可以随时执行此操作。调用此方法不会销毁任何对象。它确实允许平台在适当的时间处理垃圾回收,这就是我立即调用它的原因。

创建 SourceBuffer

现在,我们需要创建 SourceBuffer,它是真正负责在媒体源和媒体元素之间传送数据的对象。SourceBuffer 必须与您要加载的媒体文件类型相关。

在实践中,您可以通过使用适当的值调用 addSourceBuffer() 来实现此目的。请注意,在以下示例中,MIME 类型字符串包含一个 MIME 类型和 两个编解码器。这是视频文件的 MIME 字符串,但它为文件的视频和音频部分使用了不同的编解码器。

MSE 规范版本 1 允许用户代理在是否同时需要 MIME 类型和编解码器方面存在差异。某些用户代理不要求,但允许仅使用 MIME 类型。某些用户代理(例如 Chrome)要求为不自行描述编解码器的 MIME 类型提供编解码器。与其尝试理清所有这些问题,不如直接将这两者都包含在内。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  <strong>
    var mime = 'video/webm; codecs="opus, vp09.00.10.08"'; // e.target refers to
    the mediaSource instance. // Store it in a variable so it can be used in a
    closure. var mediaSource = e.target; var sourceBuffer =
    mediaSource.addSourceBuffer(mime); // Fetch and process the video.
  </strong>;
}

获取媒体文件

如果您在互联网上搜索 MSE 示例,会发现很多使用 XHR 检索媒体文件的示例。为了更具前沿性,我将使用 Fetch API 及其返回的 Promise。如果您尝试在 Safari 中执行此操作,则必须使用 fetch() polyfill,否则无法正常运行。

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  <strong>
    fetch(videoUrl) .then(function(response){' '}
    {
      // Process the response object.
    }
    );
  </strong>;
}

正式版播放器会提供同一文件的多个版本,以支持不同的浏览器。它可以为音频和视频使用单独的文件,以便根据语言设置选择音频。

现实世界的代码还会包含不同分辨率的媒体文件的多个副本,以便适应不同的设备功能和网络条件。此类应用能够使用范围请求或片段以分块加载和播放视频。这样,系统就可以在播放媒体内容时适应网络状况。您可能听说过 DASH 或 HLS,这两种方法都可以实现此目的。本文无法对此主题进行完整讨论。

处理响应对象

代码看起来已完成,但媒体无法播放。我们需要将媒体数据从 Response 对象获取到 SourceBuffer

将数据从响应对象传递到 MediaSource 实例的典型方法是从响应对象获取 ArrayBuffer 并将其传递给 SourceBuffer。首先调用 response.arrayBuffer(),它会将 Promise 返回给缓冲区。在我的代码中,我将此 promise 传递给第二个 then() 子句,并将其附加到 SourceBuffer

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      <strong>return response.arrayBuffer();</strong>
    })
    <strong>.then(function(arrayBuffer) {
      sourceBuffer.appendBuffer(arrayBuffer);
    });</strong>
}

调用 endOfStream()

附加所有 ArrayBuffers 后,如果预计没有其他媒体数据,请调用 MediaSource.endOfStream()。这会将 MediaSource.readyState 更改为 ended 并触发 sourceended 事件。

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      <strong>sourceBuffer.addEventListener('updateend', function(e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });</strong>
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

最终版本

以下是完整的代码示例。希望您对媒体源扩展有所了解。

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log('The Media Source Extensions API is not supported.');
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp09.00.10.08"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function (response) {
      return response.arrayBuffer();
    })
    .then(function (arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function (e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

反馈