MSE与流式媒体播放

22 年 12 月 4 日 星期日 (已编辑)
2577 字
13 分钟

Media Source Extensions (MSE) 是一个Web API,它于2013年首次提出,旨在解决Web上视频流播放的问题。

MSE旨在解决Web上视频流播放的灵活性、可控性和功能性限制,使开发者能够实现更高级的视频流控制和管理。例如,它允许开发者根据网络条件动态切换视频质量,实现类似YouTube的自适应流播放;支持如Netflix那样的无缝广告插入;或者像Twitch直播平台那样高效处理实时流媒体,从而大大提升了Web平台上复杂视频应用的实现可能性。

Media Source Extensions (MSE) API

  1. 它允许JavaScript动态创建媒体流。
  2. 这些流可以被传递给HTML5的<video><audio>元素。
  3. MSE提供了一个MediaSource对象,作为媒体数据的容器。
  4. 开发者可以向这个容器中添加或移除媒体数据。

为什么使用MSE

使用MSE有以下几个重要原因:

  1. 灵活性: 允许开发者对媒体流有更多的控制。
  2. 性能: 可以实现更高效的缓冲和流切换。
  3. 兼容性: 支持多种流媒体协议,如MPEG-DASH和HLS。
  4. 用户体验: 可以实现更流畅的视频播放体验,减少卡顿。
  5. 功能扩展: 为高级视频功能(如广告插入、内容保护等)提供了基础。

MSE Quick Start

首先,创建一个MediaSource对象并将其与video元素关联:

javascript
const mediaSource = new MediaSource();
const video = document.querySelector('video');
video.src = URL.createObjectURL(mediaSource);

接下来,等待MediaSource准备就绪,然后创建SourceBuffer:

javascript
mediaSource.addEventListener('sourceopen', () => {
  const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');

  // 接下来的步骤将在这里继续
});

现在,我们可以开始向SourceBuffer中添加媒体数据。这通常涉及从服务器获取数据,然后将其添加到SourceBuffer中:

javascript
fetch('video.mp4')
  .then((response) => response.arrayBuffer())
  .then((data) => {
    sourceBuffer.appendBuffer(data);
  });

最后,当所有数据都已添加完毕时,我们可以结束流:

javascript
sourceBuffer.addEventListener('updateend', () => {
  if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
    mediaSource.endOfStream();
  }
});

MSE的核心概念和组成部分

1. MediaSource 对象

MediaSource 是 MSE API 的核心,它代表了一个媒体资源的容器。你可以把它想象成一个虚拟的媒体文件,我们可以往里面添加或删除媒体数据。

javascript
const mediaSource = new MediaSource();

主要属性和方法:

  • readyState: 这个属性表示当前 MediaSource 的状态。它有三个可能的值:

  • 'closed': MediaSource 还没有附加到媒体元素上,或者已经被分离。

  • 'open': MediaSource 已经附加到媒体元素上,并准备接收数据。

  • 'ended': 数据已经全部添加完毕,流已经结束。

  • duration: 这个属性用于设置或获取媒体的总时长(以秒为单位)。例如,如果你知道视频总长是 60 秒,你可以这样设置:

javascript
mediaSource.duration = 60;
  • addSourceBuffer(): 这个方法用于创建并添加一个新的 SourceBuffer。你需要指定 MIME 类型和编解码器:
javascript
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
  • endOfStream(): 当你已经添加了所有的媒体数据,调用这个方法来表示流的结束:
javascript
mediaSource.endOfStream();

2. SourceBuffer 对象

SourceBuffer 是实际存储媒体数据的缓冲区。你可以把它想象成一个容器,我们往里面放入实际的音频或视频数据。

javascript
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');

主要属性和方法:

  • mode: 这个属性设置或获取追加模式。有两种模式:

  • 'segments': 用于已知时间戳的媒体片段。每个片段的时间戳都是独立的。

  • 'sequence': 用于未知时间戳的媒体片段。时间戳会根据之前的片段自动计算。

javascript
sourceBuffer.mode = 'segments';
  • updating: 这是一个布尔值,表示当前是否正在更新缓冲区。在添加或删除数据时,这个值会变为 true。
  • appendBuffer(): 这个方法用于添加媒体数据到缓冲区。你需要传入一个 ArrayBuffer 或 ArrayBufferView:
javascript
sourceBuffer.appendBuffer(mediaData);
  • remove(): 这个方法用于从缓冲区移除一段时间范围的数据。这在管理内存使用时很有用:
javascript
sourceBuffer.remove(0, 10); // 移除前 10 秒的数据

appendBuffer重要特性

  1. 异步操作appendBuffer 是一个异步操作。调用后,它会立即返回,但数据的实际添加是在后台进行的。
  2. 更新状态:当 appendBuffer 被调用时,SourceBuffer 的 updating 属性会变为 true。当数据添加完成后,updating 会变回 false
  3. 事件触发:数据添加完成后,会触发 updateend 事件。如果出错,则会触发 error 事件。
  4. 队列机制:如果在 updatingtrue 时再次调用 appendBuffer,新的调用会被加入队列,等待当前操作完成后再执行。
  5. 内存管理:反复调用 appendBuffer 会增加内存使用。在实际应用中,可能需要通过 remove() 方法来管理缓冲区大小。
typescript
const appendBufferAsync = (sourceBuffer: SourceBuffer, data: Uint8Array): Promise<void> => {
  return new Promise((resolve, reject) => {
    const update = () => {
      const onUpdateEnd = () => {
        sourceBuffer.removeEventListener('updateend', onUpdateEnd);
        sourceBuffer.removeEventListener('error', onError);
        resolve();
      };

      const onError = (e: Event) => {
        sourceBuffer.removeEventListener('updateend', onUpdateEnd);
        sourceBuffer.removeEventListener('error', onError);
        reject(new Error('Error appending buffer: ' + e));
      };

      sourceBuffer.addEventListener('updateend', onUpdateEnd);
      sourceBuffer.addEventListener('error', onError);

      try {
        sourceBuffer.appendBuffer(data);
      } catch (e) {
        onError(e);
      }
    };

    if (!sourceBuffer.updating) {
      update();
    } else {
      sourceBuffer.addEventListener('updateend', update, { once: true });
    }
  });
};
typescript
try {
  await appendBufferAsync(sourceBuffer, data);
  console.log('Buffer appended successfully');
} catch (error) {
  console.error('Failed to append buffer:', error);
}

3. MIME 类型和编解码器

MIME 类型和编解码器信息告诉浏览器如何解析和解码媒体数据。这是创建 SourceBuffer 时必须提供的信息。

javascript
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';

在这个例子中:

  • video/mp4 是 MIME 类型,表示这是一个 MP4 格式的视频。

  • codecs="avc1.42E01E, mp4a.40.2" 指定了具体的视频和音频编解码器:

  • avc1.42E01E 表示使用 H.264 基准配置的视频编码。

  • mp4a.40.2 表示使用 AAC-LC 音频编码。

不同的浏览器支持不同的编解码器,所以在实际使用时,你可能需要检查浏览器的兼容性。

4. 时间范围(TimeRanges)

TimeRanges 对象表示已缓冲的媒体片段的时间范围。它可以帮助你了解哪些部分的媒体已经被加载。

javascript
const buffered = sourceBuffer.buffered;

buffered 是一个 TimeRanges 对象,它可能包含多个时间范围。例如,如果你加载了 0-10 秒和 20-30 秒的视频,buffered 就会包含两个范围。

你可以这样遍历这些范围:

javascript
for (let i = 0; i < buffered.length; i++) {
  console.log(`已缓冲范围: ${buffered.start(i)} - ${buffered.end(i)}`);
}

这对于实现缓冲进度条或决定何时加载更多数据非常有用。

5. 追加模式(Append Mode)

追加模式控制如何将新的媒体片段添加到现有的缓冲区。这对于正确处理时间戳和确保平滑播放很重要。

segments 模式:

  • 适用于已知时间戳的媒体片段。
  • 每个片段的时间戳都是独立的。
  • 适合大多数点播视频场景。

sequence 模式:

  • 适用于未知时间戳的媒体片段。
  • 时间戳会根据之前的片段自动计算。
  • 适合某些直播流场景。
javascript
sourceBuffer.mode = 'segments';

MSE的相关应用举例

  1. 通过MSE可以允许开发者使用Fetch API精确操作媒体的获取和加载过程:
ts
fetch = async () => {
  this.sourceBuffer = this.mediaSource?.addSourceBuffer('audio/mpeg');
  this._process = 0;

  const res = await fetch(this.src);
  const reader = res.body?.getReader();
  if (!reader) return [];

  const contentLengthHeader = res.headers.get('Content-Length'); // requires CORS access-control-expose-headers: content-length
  const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0;
  let totalRead = 0;

  const read = async () => {
    const { value, done } = await reader.read();

    if (value) {
      totalRead += value.byteLength;
      this._process = totalRead / totalBytes;
      logger.http(
        'fetchMusicStream: process',
        this._process,
        totalBytes / (1024 * 1024),
        'MB, ',
        totalRead / (1024 * 1024),
        'MB',
      );

      this._downloadProgress({ totalRead, totalBytes });

      // _readIntoBuffer
      await appendBufferAsync(this.sourceBuffer!, value);
    }

    if (!done) {
      return read();
    } else {
      this.mediaSource?.endOfStream();
    }
  };

  return read();
};
  1. 也可以实现视频文件的分段加载:
js
class MSEPlayer {
  constructor(videoElement) {
    this.videoElement = videoElement;
    this.mediaSource = new MediaSource();
    this.sourceBuffer = null;
    this.videoElement.src = URL.createObjectURL(this.mediaSource);
    this.initEvents();
    this.segmentUrls = []; // 存储视频分段的 URL
    this.currentSegment = 0;
  }

  initEvents() {
    this.mediaSource.addEventListener('sourceopen', () => {
      console.log('MediaSource opened');
      this.sourceBuffer = this.mediaSource.addSourceBuffer(
        'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
      );
      this.sourceBuffer.addEventListener('updateend', () => {
        this.loadNextSegment();
      });
    });
  }

  setSegments(urls) {
    this.segmentUrls = urls;
    this.currentSegment = 0;
  }

  async loadNextSegment() {
    if (this.currentSegment >= this.segmentUrls.length) {
      if (this.mediaSource.readyState === 'open') {
        this.mediaSource.endOfStream();
      }
      return;
    }

    try {
      const response = await fetch(this.segmentUrls[this.currentSegment]);
      const data = await response.arrayBuffer();
      this.appendBuffer(data);
      this.currentSegment++;
    } catch (error) {
      console.error('Error loading segment:', error);
    }
  }

  appendBuffer(buffer) {
    if (this.sourceBuffer.updating || this.mediaSource.readyState !== 'open') {
      setTimeout(() => this.appendBuffer(buffer), 50);
      return;
    }

    this.sourceBuffer.appendBuffer(buffer);
  }

  play() {
    this.videoElement.play();
  }

  pause() {
    this.videoElement.pause();
  }
}

// 初始化播放器
document.addEventListener('DOMContentLoaded', () => {
  const videoElement = document.getElementById('videoElement');
  const player = new MSEPlayer(videoElement);

  document.getElementById('loadButton').addEventListener('click', () => {
    // 假设我们有三个视频分段
    player.setSegments([
      'https://example.com/video-segment1.mp4',
      'https://example.com/video-segment2.mp4',
      'https://example.com/video-segment3.mp4',
    ]);
    player.loadNextSegment();
  });

  document.getElementById('playButton').addEventListener('click', () => {
    player.play();
  });

  document.getElementById('pauseButton').addEventListener('click', () => {
    player.pause();
  });
});
  1. 录制和回放
javascript
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((stream) => {
  const mediaRecorder = new MediaRecorder(stream);
  const chunks = [];

  mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
  mediaRecorder.onstop = () => {
    const blob = new Blob(chunks, { type: 'video/webm' });
    const url = URL.createObjectURL(blob);

    // 使用 MSE 播放录制的内容
    const video = document.createElement('video');
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);

    mediaSource.addEventListener('sourceopen', () => {
      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp8,opus"');
      fetchAndAppend(url, sourceBuffer);
    });
  };

  mediaRecorder.start();
  setTimeout(() => mediaRecorder.stop(), 5000); // 录制 5 秒
});

function fetchAndAppend(url, sourceBuffer) {
  fetch(url)
    .then((response) => response.arrayBuffer())
    .then((data) => {
      sourceBuffer.appendBuffer(data);
    });
}
  1. 实时流媒体处理
  • 捕获直播内容
  • 使用 MSE 进行实时处理(如添加滤镜、叠加图形等)
javascript
class LiveStreamProcessor {
  constructor() {
    this.inputStream = null;
    this.processedStream = null;
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d');
  }

  async startProcessing() {
    this.inputStream = await navigator.mediaDevices.getUserMedia({ video: true });
    const inputVideo = document.createElement('video');
    inputVideo.srcObject = this.inputStream;
    inputVideo.play();

    const mediaSource = new MediaSource();
    const outputVideo = document.createElement('video');
    outputVideo.src = URL.createObjectURL(mediaSource);

    mediaSource.addEventListener('sourceopen', () => {
      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs=vp9');

      const processFrame = () => {
        this.ctx.drawImage(inputVideo, 0, 0, this.canvas.width, this.canvas.height);
        // 在这里添加图像处理逻辑,如滤镜、叠加等

        this.canvas.toBlob((blob) => {
          blob.arrayBuffer().then((buffer) => {
            if (!sourceBuffer.updating) {
              sourceBuffer.appendBuffer(buffer);
            }
          });
        }, 'video/webm');

        requestAnimationFrame(processFrame);
      };

      processFrame();
    });

    this.processedStream = outputVideo.captureStream();
    // 现在可以使用 this.processedStream 进行 WebRTC 传输等
  }
}

引用自web dev

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

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

MSE polyfill 参考 shaka player

文章标题:MSE与流式媒体播放

文章作者:shirtiny

文章链接:https://kizamu.anror.com/posts/media-source-extensions[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。