所有这些网站实际上仍然使用video标签。但是,它们不只是在src属性中设置视频文件,而是使用功能更强大的Web API(Media Source Extensions)。
Media Source Extensions
“Media Source Extensions”(通常简称为“ MSE”)是 W3C 的一种规范,当今大多数浏览器都在实现。它的创建是为了直接使用 HTML 和 JavaScript 允许那些复杂的媒体使用案例。
这些“扩展”将 MediaSource 对象添加到 JavaScript。顾名思义,这将是视频的源,或更简单地说,这是代表我们视频数据的对象。
如前文所述,我们仍然使用 HTML5 视频标签。也许更令人惊讶的是,我们仍然使用它的 src 属性。仅这次,我们不添加视频链接,我们正在添加一个链接到 MediaSource 对象。
您可能对最后一句话感到困惑。我们在这里不是在讨论URL,而是在讨论 JavaScript 语言的抽象概念,如何将其称为 HTML 中定义的视频标签上的 URL?
为了允许这种用例,W3C定义了 URL.createObjectURL 静态方法。该API允许创建一个URL,该URL实际上将不引用在线可用资源,而是直接引用在客户端上创建的JavaScript对象。
因此,这是将MediaSource附加到视频标签的方式:
const videoTag = document.getElementById(“my-video”);
// creating the MediaSource, just with the “new” keyword, and the URL for it
const myMediaSource = newMediaSource();
const url = URL.createObjectURL(myMediaSource);
// attaching the MediaSource to the video tag
videoTag.src = url;
就是这样!现在,您已经知道流媒体平台如何在 Web 上播放视频!
… just kidding。所以现在有了 MediaSource,但是我们应该怎么做呢?
MSE规范不止于此。它还定义了另一个概念,即SourceBuffers。
Source Buffers
视频实际上并没有直接“推送”到 MediaSource 中进行播放,而是使用 SourceBuffers。
MediaSource 包含一个或多个实例。每个都与一种内容类型相关联。
为了简单起见,我们只说三种可能的类型:
-
音讯
-
视频
-
音频和视频
实际上,“类型”是由其MIME类型定义的,其中还可能包含有关所使用的媒体编解码器的信息
SourceBuffers 都链接到单个 MediaSource,并且每个都将用于直接将 JavaScript 中的视频数据添加到 HTML5 视频标签中。
例如,一个常见的用例是在 MediaSource 上有两个源缓冲区:一个用于视频数据,另一个用于音频:
将视频和音频分离,还可以在服务器端分别对其进行管理。这样做会带来一些优势,我们将在后面看到。它是这样工作的:
const videoTag = document.getElementById(“my-video”);
const myMediaSource = new MediaSource();
const url = URL.createObjectURL(myMediaSource);
videoTag.src = url;
// 1. add source buffers
const audioSourceBuffer = myMediaSource
.addSourceBuffer(‘audio/mp4; codecs=“mp4a.40.2”’);
const videoSourceBuffer = myMediaSource
.addSourceBuffer(‘video/mp4; codecs=“avc1.64001e”’);
// 2. download and add our audio/video to the SourceBuffers
// for the audio SourceBuffer
fetch(“http://server.com/audio.mp4”).then(function(response) {
// The data has to be a JavaScript ArrayBuffer
return response.arrayBuffer();
}).then(function(audioData) {
audioSourceBuffer.appendBuffer(audioData);
});
// the same for the video SourceBuffer
fetch(“http://server.com/video.mp4”).then(function(response) {
// The data has to be a JavaScript ArrayBuffer
return response.arrayBuffer();
}).then(function(videoData) {
videoSourceBuffer.appendBuffer(videoData);
});
瞧!
现在,我们可以将视频和音频数据手动手动添加到我们的视频标签中。
现在该写音频和视频数据本身了。在上一个示例中,您可能已经注意到音频和视频数据为mp4格式。
“ mp4”是一种视频容器格式(container format),它包含相关的媒体数据,还包含多个元数据,例如描述其中包含的媒体的开始时间和持续时间。
MSE规范没有规定浏览器必须理解哪种格式。对于视频数据,两个最常见的是 mp4 和 webm 文件。到目前为止,前者是众所周知的,后者是由Google赞助的,并且基于可能更为知名的Matroska格式(“ .mkv”文件)。
两者在大多数浏览器中均受良好支持。
切片
尽管如此,这里仍然有许多问题没有答案:
-
我们是否必须等待所有内容下载完毕,才能将其推送到SourceBuffer(因此可以播放)?
-
我们如何在多种品质或语言之间切换?
-
由于媒体尚未制作完,如何播放直播内容?
在上一章的示例中,我们有一个文件代表整个音频,一个文件代表整个视频。这对于真正简单的用例就足够了,但是如果您想了解大多数流媒体网站提供的复杂性(切换语言,质量,播放实时内容等),则还不够。
在更高级的视频播放器中实际发生的是将视频和音频数据分为多个“片段”。这些片段的大小可以不同,但通常代表2到10秒的内容。
然后,所有这些视频/音频片段将形成完整的视频/音频内容。这些数据的“切片”为我们之前的示例增加了全新的灵活性:我们不必一次推送全部内容,而是可以逐步推送多个分片。
这是一个简化示例:
// … (definition of the MediaSource and its SourceBuffers)
/**
-
Fetch a video or an audio segment, and returns it as an ArrayBuffer, in a
-
Promise.
-
@param {string} url
-
@returns {Promise.}
*/
function fetchSegment(url) {
return fetch(url).then(function(response) {
return response.arrayBuffer();
});
}
// fetching audio segments one after another (notice the URLs)
fetchSegment(“http://server.com/audio/segment0.mp4”)
.then(function(audioSegment0) {
audioSourceBuffer.appendBuffer(audioSegment0);
})
.then(function() {
return fetchSegment(“http://server.com/audio/segment1.mp4”);
})
.then(function(audioSegment1) {
audioSourceBuffer.appendBuffer(audioSegment1);
})
.then(function() {
return fetchSegment(“http://server.com/audio/segment2.mp4”);
})
.then(function(audioSegment2) {
audioSourceBuffer.appendBuffer(audioSegment2);
})
// …
// same thing for video segments
fetchSegment(“http://server.com/video/segment0.mp4”)
.then(function(videoSegment0) {
videoSourceBuffer.appendBuffer(videoSegment0);
});
// …
这意味着我们在服务器端也有那些多个段。在前面的示例中,我们的服务器至少包含以下文件:
./audio/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4
./video/
└── segment0.mp4
注意:音频或视频文件可能不会在服务器端真正进行切片,客户端可能会使用Range HTTP标头代替来获取切片的文件(或者,实际上,服务器可能会根据您的请求进行任何操作您返回具体内容)。
但是,这些情况是实现细节。在这里,我们将始终认为服务器端具有这些分片文件。
所有这些意味着, 我们不必等待整个音频或视频内容下载就可以开始播放。我们通常只需要第一部分。
当然,大多数播放器并不像我们在此处那样为每个视频和音频段手动执行此逻辑,但是他们遵循相同的想法:依次下载段并将其推入源缓冲区。
看到这种逻辑在现实生活中发生的一种有趣方式是,可以在Firefox / Chrome / Edge上打开网络监视器(在Linux或Windows上,键入“ Ctrl + Shift + i”,然后转到“网络”标签,在Mac上应依次为Cmd + Alt + i和“网络”),然后在您喜欢的流媒体网站中启动视频。
您应该可以看到各种视频和音频片段正在快速下载:
顺便说一句,您可能已经注意到,我们的段只是\被推送到源缓冲区中,而没有指示 WHERE, 参考时间正确的位置的地方进行添加。
实际上,片段的容器确实定义了应将它们放入整个媒体的时间。这样,我们不必在JavaScript中立即进行同步。
自适应码流 Adaptive Streaming
许多视频播放器具有“自动播放清晰度”功能,根据用户的网络和处理能力自动选择具体视频质量。
这是称为自适应流的网络播放器的核心问题。
借助媒体分片的概念,也可以启用此行为。
在服务器端,段实际上是用多种质量编码的。例如,我们的服务器可能存储了以下文件:
./audio/
├── ./128kbps/
| ├── segment0.mp4
| ├── segment1.mp4
| └── segment2.mp4
└── ./320kbps/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4
./video/
├── ./240p/
| ├── segment0.mp4
| ├── segment1.mp4
| └── segment2.mp4
└── ./720p/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4
然后,网络播放器将随着网络或CPU条件的变化自动选择正确的段进行下载。
这完全是用JavaScript完成的。例如,对于音频片段,它可能看起来像这样:
/**
-
Push audio segment in the source buffer based on its number
-
and quality
-
@param {number} nb
-
@param {string} language
-
@param {string} wantedQuality
-
@returns {Promise}
*/
function pushAudioSegment(nb, wantedQuality) {
// The url begins to be a little more complex here:
const url = “http://my-server/audio/” +
wantedQuality + “/segment” + nb + “.mp4”);
return fetch(url)
.then((response) => response.arrayBuffer());
.then(function(arrayBuffer) {
audioSourceBuffer.appendBuffer(arrayBuffer);
});
}
/**
-
Translate an estimated bandwidth to the right audio
-
quality as defined on server-side.
-
@param {number} bandwidth
-
@returns {string}
*/
function fromBandwidthToQuality(bandwidth) {
return bandwidth > 320e3 ? “320kpbs” : “128kbps”;
}
// first estimate the bandwidth. Most often, this is based on
// the time it took to download the last segments
const bandwidth = estimateBandwidth();
const quality = fromBandwidthToQuality(bandwidth);
pushAudioSegment(0, quality)
.then(() => pushAudioSegment(1, quality))
.then(() => pushAudioSegment(2, quality));
如您所见,我们将不同质量的段组合在一起没有问题,这里的 JavaScript 方面一切都是透明的。在任何情况下,容器文件都包含足够的信息,以使此过程平稳运行。
切换语言
在更复杂的网络视频播放器上,例如 Netflix,Amazon Prime Video 或 MyCanal 上的视频播放器,还可以根据用户设置在多种音频语言之间进行切换。
既然您知道了什么,对您来说,完成此功能的方法应该看起来很简单。
像自适应流一样,我们在服务器端也有许多段:
./audio/
├──./esperanto/
| ├──segment0.mp4
| ├──segment1.mp4
| └──segment2.mp4
└── ./french/
├──segment0.mp4
├──segment1.mp4
└──segment2.mp4
./video/
├──segment0.mp4
├──segment1.mp4
└── segment2.mp4
这次,视频播放器必须不根据客户端的功能而是根据用户的喜好在语言之间进行切换。
对于音频段,这是客户端上的代码:
// …
/**
-
Push audio segment in the source buffer based on its number and language.
-
@param {number} nb
-
@param {string} language
-
@returns {Promise}
*/
function pushAudioSegment(nb, language) {
// construct dynamically the URL of the segment
// and push it to the SourceBuffer
const url = “http://my-server/audio/” +
language + “/segment” + nb + “.mp4”
return fetch(url);
.then((response) => response.arrayBuffer());
.then(function(arrayBuffer) {
audioSourceBuffer.appendBuffer(arrayBuffer);
});
}
// recuperate in some way the user’s language
const language = getUsersLanguage();
pushAudioSegment(0, language)
.then(() => pushAudioSegment(1, language))
.then(() => pushAudioSegment(2, language));
您可能还希望在切换语言时“清除”以前的SourceBuffer的内容,以避免混合多种语言的音频内容。
这可以通过SourceBuffer.prototype.remove方法完成,该方法以秒为单位的开始和结束时间:
audioSourceBuffer.remove(0, 40);
当然,也可以将自适应流和多种语言结合在一起。我们可以这样组织服务器:
./audio/
├──./esperanto/
| ├──./128kbps/
| | ├──segment0.mp4
| | ├──segment1.mp4
| | └──segment2.mp4
| └── …/320kbps/
| ├──segment0.mp4
| ├──segment1.mp4
| └──segment2.mp4└──./
french/
├──./128kbps/
| ├──segment0.mp4
| ├──segment1.mp4
| └──segment2.mp4
└── ./320kbps/
├──segment0.mp4
├──segment1.mp4
└──segment2.mp4
./video/
├──./240p/
| ├──segment0.mp4
| ├──segment1.mp4
| └──segment2.mp4
└── ./720p/
├──segment0.mp4
├──segment1.mp4
└──segment2.mp4
而我们的客户将不得不同时管理语言和网络条件:
/**
-
Push audio segment in the source buffer based on its number, language and quality
-
@param {number} nb
-
@param {string} language
-
@param {string} wantedQuality
-
@returns {Promise}
*/
function pushAudioSegment(nb, language, wantedQuality) {
// The url begins to be a little more complex here:
const url = “http://my-server/audio/” +
language + “/” + wantedQuality + “/segment” + nb + “.mp4”);
return fetch(url)
.then((response) => response.arrayBuffer());
.then(function(arrayBuffer) {
audioSourceBuffer.appendBuffer(arrayBuffer);
});
}
const bandwidth = estimateBandwidth();
const quality = fromBandwidthToQuality(bandwidth);
const language = getUsersLanguage();
pushAudioSegment(0, language, quality)
.then(() => pushAudioSegment(1, language, quality))
.then(() => pushAudioSegment(2, language, quality));
如您所见,现在有很多方法可以定义相同的内容。
这揭示了分开的视频和音频段相对于整个文件的另一个优点。对于后者,我们将不得不在服务器端结合各种可能性,这可能会占用更多空间:
segment0_video_240p_audio_esperanto_128kbps.mp4
segment0_video_240p_audio_esperanto_320kbps.mp4
segment0_video_240p_audio_french_128kbps.mp4
segment0_video_240p_audio_french_320kbps.mp4
segment0_video_720p_audio_esperanto_128kbps.mp4
segment0_video_720p_audio_esperanto_320kbps.mp4
segment0_video_720p_audio_french_128kbps.mp4
segment0_video_720p_audio_french_320kbps.mp4
segment1_video_240p_audio_esperanto_128kbps.mp4
segment1_video_240p_audio_esperanto_320kbps.mp4
segment1_video_240p_audio_french_128kbps.mp4
segment1_video_240p_audio_french_320kbps.mp4
segment1_video_720p_audio_esperanto_128kbps.mp4
segment1_video_720p_audio_esperanto_320kbps.mp4
segment1_video_720p_audio_french_128kbps.mp4
segment1_video_720p_audio_french_320kbps.mp4
segment2_video_240p_audio_esperanto_128kbps.mp4
segment2_video_240p_audio_esperanto_320kbps.mp4
segment2_video_240p_audio_french_128kbps.mp4
segment2_video_240p_audio_french_320kbps.mp4
segment2_video_720p_audio_esperanto_128kbps.mp4
segment2_video_720p_audio_esperanto_320kbps.mp4
segment2_video_720p_audio_french_128kbps.mp4
segment2_video_720p_audio_french_320kbps.mp4
Here we have more files, with a lot of redundancy (the
这里我们有更多的文件,并且有很多冗余(多个文件中包含完全相同的视频数据)。
如您所见,在服务器端效率很低。但这在客户端也很不利,因为切换音频语言可能会导致您也重新下载视频(带宽成本很高)。
直播
我们还没有谈论直播。
网络上的直播流媒体(twitch.tv,YouTube实时流媒体…)变得非常普遍,并且由于我们的视频和音频文件已分段,因此再次大大简化了这一过程。
为了说明它基本上以最简单的方式工作,让我们考虑一个4秒钟前才开始直播传输的 YouTube 频道。
如果我们的片段长2秒,那么我们应该已经在YouTube的服务器上生成了两个音频片段和两个视频片段:
-
两个代表从0秒到2秒的内容(1个音频+ 1个视频)
-
两个代表2秒到4秒(同样是1个音频+ 1个视频)
./audio/
├──segment0s.mp4
└── segment2s.mp4
./video/
├──segment0s.mp4
└── segment2s.mp4
在5秒钟时,我们还没有时间生成下一个片段,因此,到目前为止,服务器具有完全相同的可用内容。
6秒钟后,可以生成一个新的段,我们现在有:
./audio/
├──segment0s.mp4
├──segment2s.mp4
└── segment4s.mp4
./video/
├──segment0s.mp4