Media Source Extensions (MSE) 是一个Web API,它于2013年首次提出,旨在解决Web上视频流播放的问题。
MSE旨在解决Web上视频流播放的灵活性、可控性和功能性限制,使开发者能够实现更高级的视频流控制和管理。例如,它允许开发者根据网络条件动态切换视频质量,实现类似YouTube的自适应流播放;支持如Netflix那样的无缝广告插入;或者像Twitch直播平台那样高效处理实时流媒体,从而大大提升了Web平台上复杂视频应用的实现可能性。
Media Source Extensions (MSE) API
- 它允许JavaScript动态创建媒体流。
- 这些流可以被传递给HTML5的
<video>和<audio>元素。 - MSE提供了一个
MediaSource对象,作为媒体数据的容器。 - 开发者可以向这个容器中添加或移除媒体数据。
为什么使用MSE
使用MSE有以下几个重要原因:
- 灵活性: 允许开发者对媒体流有更多的控制。
- 性能: 可以实现更高效的缓冲和流切换。
- 兼容性: 支持多种流媒体协议,如MPEG-DASH和HLS。
- 用户体验: 可以实现更流畅的视频播放体验,减少卡顿。
- 功能扩展: 为高级视频功能(如广告插入、内容保护等)提供了基础。
MSE Quick Start
首先,创建一个MediaSource对象并将其与video元素关联:
const mediaSource = new MediaSource();
const video = document.querySelector('video');
video.src = URL.createObjectURL(mediaSource);
接下来,等待MediaSource准备就绪,然后创建SourceBuffer:
mediaSource.addEventListener('sourceopen', () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
// 接下来的步骤将在这里继续
});
现在,我们可以开始向SourceBuffer中添加媒体数据。这通常涉及从服务器获取数据,然后将其添加到SourceBuffer中:
fetch('video.mp4')
.then((response) => response.arrayBuffer())
.then((data) => {
sourceBuffer.appendBuffer(data);
});
最后,当所有数据都已添加完毕时,我们可以结束流:
sourceBuffer.addEventListener('updateend', () => {
if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
});
MSE的核心概念和组成部分
1. MediaSource 对象
MediaSource 是 MSE API 的核心,它代表了一个媒体资源的容器。你可以把它想象成一个虚拟的媒体文件,我们可以往里面添加或删除媒体数据。
const mediaSource = new MediaSource();
主要属性和方法:
-
readyState: 这个属性表示当前 MediaSource 的状态。它有三个可能的值: -
'closed': MediaSource 还没有附加到媒体元素上,或者已经被分离。
-
'open': MediaSource 已经附加到媒体元素上,并准备接收数据。
-
'ended': 数据已经全部添加完毕,流已经结束。
-
duration: 这个属性用于设置或获取媒体的总时长(以秒为单位)。例如,如果你知道视频总长是 60 秒,你可以这样设置:
mediaSource.duration = 60;
addSourceBuffer(): 这个方法用于创建并添加一个新的 SourceBuffer。你需要指定 MIME 类型和编解码器:
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
endOfStream(): 当你已经添加了所有的媒体数据,调用这个方法来表示流的结束:
mediaSource.endOfStream();
2. SourceBuffer 对象
SourceBuffer 是实际存储媒体数据的缓冲区。你可以把它想象成一个容器,我们往里面放入实际的音频或视频数据。
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
主要属性和方法:
-
mode: 这个属性设置或获取追加模式。有两种模式: -
'segments': 用于已知时间戳的媒体片段。每个片段的时间戳都是独立的。
-
'sequence': 用于未知时间戳的媒体片段。时间戳会根据之前的片段自动计算。
sourceBuffer.mode = 'segments';
updating: 这是一个布尔值,表示当前是否正在更新缓冲区。在添加或删除数据时,这个值会变为 true。appendBuffer(): 这个方法用于添加媒体数据到缓冲区。你需要传入一个 ArrayBuffer 或 ArrayBufferView:
sourceBuffer.appendBuffer(mediaData);
remove(): 这个方法用于从缓冲区移除一段时间范围的数据。这在管理内存使用时很有用:
sourceBuffer.remove(0, 10); // 移除前 10 秒的数据
appendBuffer重要特性
- 异步操作:
appendBuffer是一个异步操作。调用后,它会立即返回,但数据的实际添加是在后台进行的。 - 更新状态:当
appendBuffer被调用时,SourceBuffer 的updating属性会变为true。当数据添加完成后,updating会变回false。 - 事件触发:数据添加完成后,会触发
updateend事件。如果出错,则会触发error事件。 - 队列机制:如果在
updating为true时再次调用appendBuffer,新的调用会被加入队列,等待当前操作完成后再执行。 - 内存管理:反复调用
appendBuffer会增加内存使用。在实际应用中,可能需要通过remove()方法来管理缓冲区大小。
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 });
}
});
};
try {
await appendBufferAsync(sourceBuffer, data);
console.log('Buffer appended successfully');
} catch (error) {
console.error('Failed to append buffer:', error);
}
3. MIME 类型和编解码器
MIME 类型和编解码器信息告诉浏览器如何解析和解码媒体数据。这是创建 SourceBuffer 时必须提供的信息。
'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 对象表示已缓冲的媒体片段的时间范围。它可以帮助你了解哪些部分的媒体已经被加载。
const buffered = sourceBuffer.buffered;
buffered 是一个 TimeRanges 对象,它可能包含多个时间范围。例如,如果你加载了 0-10 秒和 20-30 秒的视频,buffered 就会包含两个范围。
你可以这样遍历这些范围:
for (let i = 0; i < buffered.length; i++) {
console.log(`已缓冲范围: ${buffered.start(i)} - ${buffered.end(i)}`);
}
这对于实现缓冲进度条或决定何时加载更多数据非常有用。
5. 追加模式(Append Mode)
追加模式控制如何将新的媒体片段添加到现有的缓冲区。这对于正确处理时间戳和确保平滑播放很重要。
segments 模式:
- 适用于已知时间戳的媒体片段。
- 每个片段的时间戳都是独立的。
- 适合大多数点播视频场景。
sequence 模式:
- 适用于未知时间戳的媒体片段。
- 时间戳会根据之前的片段自动计算。
- 适合某些直播流场景。
sourceBuffer.mode = 'segments';
MSE的相关应用举例
- 通过MSE可以允许开发者使用Fetch API精确操作媒体的获取和加载过程:
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();
};
- 也可以实现视频文件的分段加载:
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();
});
});
- 录制和回放
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);
});
}
- 实时流媒体处理
- 捕获直播内容
- 使用 MSE 进行实时处理(如添加滤镜、叠加图形等)
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