[译]Web视频播放原理:介绍

原文: How video streaming works on the web: An introduction

渴求原生视频API

从2000年代早期到晚期,网络上的视频播放主要依赖于Flash插件。这是因为在那个时候,浏览器中没有实现其他的视频流播放方案。用户如果想要在网页中播放视频,只能通过安装第三方插件,比如flash或者silverlight,来播放视频,否则根本无法观看视频。

为了填写这一空白,网页超文本应用技术工作小组(WHATWG)开始着手制订新版本的HTML标准,其中就包括原生视频和音频(无需插件)播放。这个新版本的HTML标准就是大家熟知的HTML5.

于是HTML5给web开发带来<Video>标签,这个新标签允许开发者直接通过HTML加载视频资源,类似于通过<img>标签加载图片资源。这个一个很酷的事情,但是从媒体网站的角度看来,这种类似图片加载的方式似乎不足以取代flash,原因如下:

  1. 用户可能需要在多个质量视频间进行即时切换
  2. 直播是另一个使用场景,看起来很难以这种方式实现
  3. 如果内容像Netflix一样流式传输,那么基于用户偏好更新内容的音频语言呢?

值得庆幸的是,随着HTML5规范的到来,以上所有这些问题都可以在大多数浏览器上解决。本文将详细介绍今天的web如何做到这些的。

Video标签

前面有提到,使用HTML5在网页里加载视频是相当直观,你只需要在你的页面中添加一个video标签以及给它一些相关的属性。

1
2
3
4
5
6
7
8
9
<html>
<head>
<meta charset="UTF-8">
<title>My Video</title>
</head>
<body>
<video src="some_video.mp4" width="1280px" height="720px" />
</body>
</html>

video标签将允许您的页面直接在任何支持相应编解码器(当然还有HTML5)的浏览器上流式传输some_video.mp4。同时HTML5还提供了丰富的API,比如视频的播放,暂停,快进以及改变播放速率。

然而,现如今我们在网站上看到的视频拥有更过更复杂的行为远远超过了HTML5提供给我们的。例如,视频质量切换和实时流媒体。这些网站实际上仍然使用视频标签。但是,他们不是简单地在src属性中设置视频地址,而是使用更强大的Web API,即媒体资源扩展(Media Source Extensions)。

媒体资源扩展(Media Source Extensions)

媒体资源扩展(Media Source Extensions简称“MSE”, 是一个现今多数浏览器都遵守的规范。它创建来是为了让HTML和JavaScript允许那些复杂的媒体用例。

这些“扩展”将MediaSource对象添加到JavaScript,顾名思义,它就是视频资源,或者更简单地说,这是表示我们视频数据的对象。如上面所说,我们仍然会使用<video>, 也许更令人惊讶的是,我们仍然使用是src属性,只是我们不再链接到一个视频文件,而是链接到一个媒体资源对象。

你可能对而是链接到一个媒体资源对象感到很疑惑,这里我们不再说它是一个URL,而我们说它是一个JavaScript语言中的抽象概念,那怎么可以将它称为视频标签上的URL,这是在HTML中定义的?

为了允许这种用例,W3C定义了URL.createObjectURL静态方法。此API允许创建一个URL,该URL实际上不会引用在线可用的资源,而是直接引用在客户端上创建的JavaScript对象。这就是MediaSource附加到视频标签的原理。

1
2
3
4
5
6
7
8
const videoTag = document.getElementById("my-video");

// creating the MediaSource, just with the "new" keyword, and the URL for it
const myMediaSource = new MediaSource();
const url = URL.createObjectURL(myMediaSource);

// attaching the MediaSource to the video tag
videoTag.src = url;

就是这样!现在你知道流媒体平台如何在网络上播放视频了!

开个玩笑。所以现在我们有了MediaSource,但是我们应该怎么做呢?

MSE规范并不止于此。它还定义了另一个概念SourceBuffers。

资源缓冲

视频实际上并未直接“推入”MediaSource进行播放,SourceBuffer用于此目的。

一个MediaSource包含一个或多个SourceBuffer实例。每个都与某一种类型内容相关联。这里简单地说,有三种可能的类型:

  1. audio
  2. video
  3. audio 和 video

⚠️: 实际上,内容的“类型”由其MIME类型定义,其还可以包括关于所使用的媒体编解码器的信息

SourceBuffer都会链接到单个MediaSource,其中每个SourceBuffer都会被JavaScript用来将视频数据直接添加到HTML5视频标签。如下图所示:
Relations between the video tag, the MediaSource, the SourceBuffers and the actual data

分离视频和音频还允许在服务器端单独管理它们。这样做会带来一些好处,我们稍后会看到。这是它的工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// -- 创建MediaSource并关联到video  --
const videoTag = document.getElementById("my-video");
const myMediaSource = new MediaSource();
const url = URL.createObjectURL(myMediaSource);
videoTag.src = url;

// 1. 为MediaSource添加sourceBuffer
const audioSourceBuffer = myMediaSource
.addSourceBuffer(`audio/mp4; codecs="mp4a.40.2"`);
const videoSourceBuffer = myMediaSource
.addSourceBuffer(`video/mp4; codecs="avc1.64001e"`);

// 2. 下载并添加audio/video到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”是一种容器格式,它不仅包含相关的媒体数据,还包含多个元数据,例如,它所包含的媒体的开始时间和持续时间。。

MSE规范没有规定浏览器必须理解哪种格式。对于视频数据,最常见的两个是mp4和webm文件。前者现在非常有名,后者由谷歌赞助并基于可能更为人所知的Matroska格式(“.mkv”文件)。大多数浏览器对这两种格式都有很好的支持。

尽管如此,许多问题仍未得到解答:

  1. 我们是否必须等待下载整个内容,才能将其推送到SourceBuffer(因此能够播放)?
  2. 我们如何在多种质量或语言之间切换
  3. 如何在媒体资源尚未完成时播放实况内容(即如何实现直播)?

媒体片段

在上面的代码中,我们有一个http://server.com/audio.mp4文件代表整个音频,一个http://server.com/vedio.mp4文件代表整个视频。这对于非常简单的用例来说已经足够了,但如果您想要了解大多数流媒体网站提供的复杂性(切换语言,质量,播放实时内容等),这还不够。

在更高级的视频播放器中实际发生的是, 音/视频数据被分成多个“片段”。这些段可以有各种大小,但它们通常代表2到10秒的内容。所有这些音/视频片段形成完整的音/视频内容。这些数据块为我们之前的示例增加了一个全新的灵活性:不是一次推送整个内容,我们可以逐步推动多个音/视频片段。

下面有一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ...
// (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.<ArrayBuffer>}
*/
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);
});
// ...

从上面代码可以看出,在服务器端存在多个片段资源:

1
2
3
4
5
6
./audio/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4
./video/
└── segment0.mp4

⚠️ 服务器端的音/视频文件可能并没有真正被分段,客户端可以使用HTTP range request来获取分段的那些文件片段(也有可能服务器真的可以根据您的请求发送你想要的备份片段)。但是,这些情况都是实现细节。本文中我们认为服务器端有分段。

所有这些都意味着,我们不必等待整个音/视频内容下载后才开始播放。我们经常只需媒体片段。当然,大多数播放器不会像我们代码那样为每个视频和音频片段手动执行此逻辑,但他们遵循相同的想法:按顺序下载片段并将其推入资源缓冲区(SourceBuffer)。

一种有趣的方式看到这个逻辑发生在现实生活中可以打开网络监视器在Firefox /铬/边缘(在Linux或windows类型“Ctrl + Shift + i”和“网络”选项卡,在Mac应该Cmd + Alt +然后我“网络”),然后启动一个视频在你最喜爱的流媒体网站。
你应该会看到各种视频和音频片段被快速下载:
Screenshot of the Chrome Network tab on the Rx-Player’s demo page

顺便说一下,您可能已经注意到,我们的段只是被推入源缓冲区,而没有根据时间位置指示应该推入的位置。
片段的容器实际上定义了它们应该放在整个媒体中的时间。这样,我们就不必在JavaScript中同步它了。

自适应流媒体

许多视频播放器都有“自动质量”功能,根据用户的网络和处理能力自动选择质量。这是自适应流媒体的网络播放器的核心。这种行为也是得益于媒体切片的概念。

在服务端,音/视频片段实际上是以多种质量编码的。例如,我们的服务器可以存放下面的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
./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

然后,Web播放器可以根据网络或CPU条件自动正确选择要下载的片段。这完全是用JavaScript完成的。对于音频片段,例如,它可能看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 根据片段的编号和质量推入sourceBuffer
* @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);
});
}

/**
将当前的带宽转换为对应音频服务器端定义的质量。
* @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等更复杂的网络视频播放器上,也可以根据用户设置在多种音频语言之间切换。
既然您已经知道了视频质量的切换方式,那么多语言切换的实现方式对您来说应该非常简单。
与自适应流媒体一样,我们在服务器端也有很多段:

1
2
3
4
5
6
7
8
9
10
11
12
13
./audio/
├── ./esperanto/
| ├── segment0.mp4
| ├── segment1.mp4
| └── segment2.mp4
└── ./french/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4
./video/
├── segment0.mp4
├── segment1.mp4
└── segment2.mp4

这一次,视频播放器切换语言不再是基于客户端功能,而是根据用户的偏好进行切换。
对于音频段,客户端代码大致是以下样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ...
/**
* 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方法实现,该方法以秒为开始和结束时间:

1
2
// remove pushed content from 0 to 40s
audioSourceBuffer.remove(0, 40);

当然,也可以将自适应流媒体和多语言结合起来。我们可以将服务器的文件按照以下形式组织:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
./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

这样,客户端必须管理语言和网络条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 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));

如您所见,现在有很多方法可以定义相同的内容。这揭示了分离视频和音频段相对于整个文件的另一个优势。对于后者,我们将不得不结合服务器端的所有可能性,这可能会占用更多的空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

服务端需要存放更多的文件,显然这些文件数据存在大量冗余信息(完全相同的视频数据包含在多个文件中),并且这种方式这在客户端也有一个缺点,因为切换音频语言可能会导致您重新下载视频(具有高带宽成本)。

实况内容

我们还没有谈论直播。
网络直播变得非常普遍(例如twitch.tv,YouTube直播流…),而且由于我们的视频和音频文件是分段的,这又一次大大简化了。

为用最简单的方式解释直播的基本原理,让我们考虑一个刚刚在4秒前开始直播的YouTube频道。if我们的片段内容是2秒长,那么此时YouTube的服务器应该已经产生了两个音频片段和两个视频片段:

1
2
3
4
5
6
./audio/
├── segment0s.mp4
└── segment2s.mp4
./video/
├── segment0s.mp4
└── segment2s.mp4

在直播开始的第5秒时,服务器此时还没有时间生成下一个段(第3个),所以服务器具有完全相同的可用内容。直到第6秒后,服务器产生了一个新的片段:

1
2
3
4
5
6
7
8
./audio/
├── segment0s.mp4
├── segment2s.mp4
└── segment4s.mp4
./video/
├── segment0s.mp4
├── segment2s.mp4
└── segment4s.mp4

这在服务器端是非常合乎逻辑的,直播内容实际上并不是真正意义上连续的,它们像非实时内容一样被分段,只是随着时间的推移,直播的片段继续逐渐出现。

那么,我们如何从JS知道在某个时间点上服务器上有哪些片段是可用的呢?

最简单的,我们可以客户端上使用计时器,用来推断新的片段在服务器端可用时的时间。我们将遵循“segmentX.mp4”命名模式,我们将每次从最后下载的一个增加“X”(segment0.mp4,然后,2秒后,Segment1.mp4等)。
然而,在许多情况下,这可能变得并不精确:

  1. 媒体内容片段持续时间并不固定,
  2. 服务器在生成片段时有延迟,
  3. 服务器可能会为了节省空间而删除太旧的视频

作为客户端,您希望尽快请求最新的片段,一旦它们可以被获取;同时也要避免在尚未生成时过早地请求它们(这将导致404 HTTP错误)。

通常使用传输协议(有时也称为流媒体协议)来解决此问题。

传输协议

对于本文来说,深入解释不同的传输协议可能过于冗长。让我们假设它们中的大多数都有相同的核心概念:Manifest。

Manifes是一个用来描述服务器上可用片段的文件。
Example of a DASH Manifest, based on XML
有了它,你可以描述在这篇文章中学到的大多数内容:

  • 哪些音频语言的内容可用,以及它们在服务器上的位置(如“在哪个URL”)
  • 可用的不同音/视频质量
  • 当然,如果在直播环境下,包括可使用的片段

下面是一些Web中最常使用的传输协议:

  • DASH

    YouTube,Netflix以及Amazon Prime Video等都在使用。 DASH的Manifest基于XML,被称为媒体呈现描述(或MPD)。

    DASH规范具有很大的灵活性,允许MPD支持大多数用例(音频描述、父级控件),并且不依赖于编码。

  • HLS

    由Apple开发,使用者包括DailyMotion,Twitch.tv等。 HLS的Manifest称为播放列表,其文件格式为m3u8 (m3u播放列表文件,用UTF-8编码)。

  • Smooth Streaming

    由微软开发,被多个微软产品和MyCanal使用。在Smooth Streaming的Manifest是基于xml的。

    真实的网络世界

    如您所见,web视频背后的核心概念在于用JavaScript动态推送的媒体片段。这种行为很快变得相当复杂,因为视频播放器需要支持很多功能:
  • 下载并解析某种Manifest文件
  • 监测当前的网络状况
  • 用户首选项(例如,首选语言)
  • 知道下载哪个部分,这至少取决于前面的两点
  • 管理一个段管道,以便在正确的时间顺序下载正确的段(同时下载每个段将是低效的:您需要最早的一个段,而不是下一个)
  • 处理字幕,通常完全用JS管理
  • 一些视频播放器还管理一个缩略图跟踪,您可以经常看到,当悬停在进度条
  • 许多服务还需要DRM管理
  • 还有很多其他的…

尽管如此,复杂的web兼容视频播放器仍然基于MediaSource和sourcebuffer。这就是为什么这些任务通常是由库执行的,库就是这样做的。通常,这些库甚至不定义用户界面。它们大多提供丰富的api,将Manifest和各种首选项作为参数,并在正确的源缓冲区的正确时间推送正确的片段。

这使得在设计媒体网站和web应用程序时具有更大的模块化和灵活性,这在本质上将是复杂的前端。