作者 | 珊若
首先,我们先来看一张图:
在多年以前,我们网页视频播放都只能依赖Flash或者其他第三方插件才能播放,后来基于HTML5的Video出来后,就渐渐的去Flash了。
直接使用HTML5 Video非常的方便:
<html>
<head>
<meta charset="UTF-8">
<title>My Video</title>
</head>
<body>
<video src="video.mp4" width="1280px" height="720px" />
</body>
</html>
现在绝大多数的网站已经从Flash播放转向了浏览器原生的Audio/Video播放,那浏览器是如何加载和解析多媒体资源的,这对于web开发者来说是一个黑盒,所以今天就跟大家一起来看一下浏览器具体是怎么实现的?
在看具体原理之前,我们先来看下Video完整的播放流程/事件。
Video 播放完整流程
所以基于上面的流程,我们现在对于视频播放首帧的定义就比较清楚了,这里主要分自动播放、点击播放
- 自动播放首帧:loadstart(视频准备开始请求加载数据)到 timeupdate (播放进度变化)
- 点击播放首帧:play(用户点击play)到 timeupdate(播放进度变化)
当然上述流程是在PC的表现,实际上针对这些事件IOS和Android下会有一些差异,这里不细讲。在大致了解了完整的播放流程后,我们就来重点看下,浏览器是如何加载和解析多媒体资源的?这里我们以Chrome为例。
Chromium 多媒体请求和解码流程
首先,早期Chromium文档中对于整体的过程是这样的(目前来看有点过时了,但是核心的原理是一致的)
大体来说,由Webkit请求创建一个媒体video标签,创建一个DOM对象,它会实例化一个WebMediaPlayerImpl,这个player是整体的控制中枢,player驱使Buffer去请求多媒体数据,然后交由FFmpeg进行多路解复用和音视频解码(FFmpeg是一个开源的第三方音视频解码库),再把解码后的数据传给相应的渲染器对象进行渲染绘制,最后让video标签去显示或者声卡进行播放。
现在最新的Chromium Media针对媒体播放管道比较全的概述如下:
通过上面👆的流程里,我们可以看到有两个关键问题需要我们重点解析:
1、FFmpeg编解码的过程是怎么样?
2、流媒体数据的传输是怎么控制的?
编解码基础
首先从我们日常使用的摄像机或者相机切入,将生成的照片和视频通过镜头里的感光元件,把收到的光转换为电子像素,一个像素是由rgba 4通道组成,在视频里面通常只有rgb 3通道,共8b * 3 = 24b = 3B即3个字节,一个1080p(1920 * 1080),帧率为30fps(一秒钟有30张图片)时长为1分钟的视频有多大呢?计算如下:
3B * 1920 * 1080 * 30 * 60 = 12441600000B = 10GB
约有10GB,一个100分钟的电影就需要占用1TB的硬盘空间,所以如果没有压缩的话这个体积是非常巨大的,但是我们看到一个1080p的mp4文件(h264,普通码率)平均1分钟的体积只有100MB左右,如下图所示:
(本地找了个差不多3min的是1080p的视频,455MB)
在这个例子里面,h264编码的mp4文件(类型可以参考上图的编解码器:H.264, AAC)压缩比达到了约100:1。
因此编码的目的就是压缩,而解码的目的就是解压缩。压缩又分为有损压缩和无损压缩,无损压缩如gzip/flac,是可以还原成原本未压缩的完整数据,而有损压缩如h264/mp3等一旦压完之后就无法再还原成更高清晰度的数据了。
视频编码通常都是有损压缩,目标是降低一点清晰度的同时让体积得到大大的减少,降低的清晰度能达到对人眼不可察觉或者几乎无法分辨的水平。主要的方法是去除视频里面的冗余信息,对于很多不是剧烈变化的场面,相邻帧里面有很多重复信息,通过帧间预测等方法分析和去除,而帧内预测可去掉同个帧里的重复信息,还有对画面观众比较关注的前景部分高码率编码,而对背景部分做低码率编码,等等,这些取决于不同的压缩算法。
视频的编码方式从MPEG-1(VCD)到MPEG-2(DVD)再到MPEG-4(数码相机),再到现在主流的网络视频用的h264和比较新的h265,包括H266也出来了,同等压缩质量下,压缩率不断地提到提升。另外还有Google主导的VP8、VP9和AV1编码(主要在WebRTC里面使用)。编码的压缩率越好,它的算法通常会更复杂,所以一些低端设备可能会扛不住进而降级使用较老的编码格式。
解码的目的就是解压缩,把数据还原成原始的像素。FFmpeg是一个很出名的开源的编解码C库,Chrome也使用了它做为它的解码器之一,并且有人把它转成了WASM(WebAssembly),可以在网页上跑。WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行,WebAssembly被设计为可以和JavaScript一起协同工作——通过使用WebAssembly的JavaScript API,我们可以把WebAssembly模块加载到一个JavaScript应用中并且在两者之间共享功能。允许在同一个应用中利用WebAssembly的性能和威力以及JavaScript的表达力和灵活性,即使我们可能并不知道如何编写WebAssembly代码。
Chromium buffer 控制
在上面流媒体传输里面有一个核心的点:buffer缓冲空间大小,如果buffer太大,那么一次性下载的数据太多,用户还没播到那里,如果buffer太小不够播放可能会经常卡住加载。在实时传输领域,实时流媒体通信的双方(比如直播场景)如果buffer太大的话会导致延迟太大,如果buffer太小那么会带来一些体验差的问题,比如拥塞控制、丢包重传等。
接下来,我们来看一下Chromium播放音视频的时候buffer是怎么控制的,它的实现是在src/media/blink/multibuffer_data_source.cc这个文件的UpdateBufferSizes函数,简单来说就是每次都往后预加载10s的播放长度,并且最大不超过50MB,最小不小于2MB,往前是保留2s播放长度。详细来说,首先要获取码率,即1s的音视频需要占用的空间,如下代码所示:
// Minimum preload buffer.
const int64_t kMinBufferPreload = 2 << 20; // 2 Mb
// Maxmimum preload buffer.
const int64_t kMaxBufferPreload = 50 << 20; // 50 Mb
// If preload_ == METADATA, preloading size will be
// shifted down this many bits. This shift turns
// one Mb into one 32k block.
// This seems to be the smallest amount of preload we can do without
// ending up repeatedly closing and re-opening the connection
// due to read calls after OnBufferingHaveEnough have been called.
const int64_t kMetadataShift = 6;
// Preload this much extra, then stop preloading until we fall below the
// preload_seconds_.value().
const int64_t kPreloadHighExtra = 1 << 20; // 1 Mb
// Default pin region size.
// Note that we go over this if preload is calculated high enough.
const int64_t kDefaultPinSize = 25 << 20; // 25 Mb
// If bitrate is not known, use this.
const int64_t kDefaultBitrate = 200 * 8 << 10; // 200 Kbps.
// Maximum bitrate for buffer calculations.
const int64_t kMaxBitrate = 20 * 8 << 20; // 20 Mbps.
// 在UpdateBufferSizes函数里实现
// Use a default bit rate if unknown and clamp to prevent overflow.
int64_t bitrate = clamp<int64_t>(bitrate_, 0, kMaxBitrate);
if (bitrate == 0)
bitrate = kDefaultBitrate;
有一个默认码率是200Kbps,最大码率不超过20Mbps,如果还不知道码率的情况下就使用默认码率。
同时在这里可以看到针对preload的控制,从video的使用文档中,我们可以看到preload不同值代表不同的策略:
- preload=none: 提示认为用户不需要查看该视频,服务器也想要最小化访问流量,换句话说就是提示浏览器该视频不需要缓存
- preload=metadata: 提示尽管认为用户不需要查看该视频,不过抓取元数据(比如:长度)还是很合理的
- preload=auto: 用户需要这个视频优先加载,换句话说就是提示如果需要的话,可以下载整个视频,即使用户并不一定会用它
- 假如不设置,默认值就是浏览器定义的了 (不同浏览器会选择自己的默认值),即使规范建议设置为 metadata
在网站实际使用MP4视频时,发现可能需要preload多次才能拿到码率,知道码率之后再获取播放速率通常为默认的1倍速,进而知道10s应该是多少空间:
// 这里的播放速率playback_rate为1
int64_t bytes_per_second = (bitrate / 8.0) * playback_rate;
// 预加载10s的数据,不超过最大,不小于最小
int64_t preload = clamp(kTargetSecondsBufferedAhead * bytes_per_second,
kMinBufferPreload, kMaxBufferPreload);
然后还发现一个比较有意思的处理,再加上当前已下载数据的10%(以下载数据的10%的速度缓慢增加缓冲,所以远远超出了预加载大小,在实际播放过程中会更平滑)
// Increase buffering slowly at a rate of 10% of data downloaded so
// far, maxing out at the preload size.
int64_t extra_buffer = std::min(
preload, url_data_->BytesReadFromCache() * kSlowPreloadPercentage / 100);
// Add extra buffer to preload.
preload += extra_buffer;
对一个50Mb的视频,播放到中间的时候,这个extra_buffer的值大概就是2.5MB。然后把preload值传给BufferReader,由它去触发请求相应的数据,这个是使用http range功能请求相应范围的字节数:
原生播放器的策略只是使用一个http连接加载不同字节范围的视频数据,而视频网站是每需要一个range的数据的时候就发一个请求。这些请求的响应状态码都是206,表示返回部分内容。如果连接断了或者服务端只返回部分数据就关闭连接,那么chrome会重新发个请求。
另外打开页面的时候chrome不会提前预加载数据,只有点击播放了才加载音视步内容,并且preload的buffer size是在加载过程中周期性更新的。
MP4 格式和解复用
这里重点讲一下我们最常见的MP4视频格式,mp4或称MPEG-4 Part 14,是一种多媒体容器格式,扩展名为.mp4。
一个视频可以有3个轨道(Track):视频、音频和文本,但是数据的存储是一维的,从1个字节到第n个字节,那么视频应该放哪里,音频应该放哪里,mp4/avi等格式做了规定,把视轨音轨合成一个mp4文件的过程就叫多路复用(mux),而把mp4文件里的音视频轨分离出来就叫多路解复用(demux),这两个词是从通信领域来的。
假设现在有个需求,需要取出用户上传视频的第一帧做为封面,这就要求我们去解析mp4文件,并做解码。
上图是用16进制表示的原始二进制内容,两个16进制(0000)就表示1个字节。
mp4是使用box盒子表示它的数据存储的,标准规定了若干种盒子类型,每种盒子存放的数据类型不一样。
根节点之下,主要包含三个节点:ftyp、moov、mdat
- ftyp:文件类型,描述遵从的规范的版本
- moov box:媒体的metadata信息
- mdat:具体的媒体数据
box可以嵌套box,每个box的前4个字节表示它占用的空间大小(上图第一个box是0x18),在前4个表示大小的字节之后紧接着的4个字节是盒子类型,值为ASCII编码(上图第一个box类型 6674 7970 -> ftyp),ftyp盒子的作用是用来标志当前文件类型,紧接着的4个字节表示它是一个微软的MPEG-4格式,即平常说的mp4。
第二个是一个moov的盒子,moov存储了盒子的metadata信息,包括有多少个音视频轨道,视频宽高是多少,有多少sample(帧),帧数据位于什么位置等等关键信息。因为这些位置信息都可以从moov这个盒子里面找到。若干个sample组成一个chunk,即一个chunk可以包含1到多个sample,chunk的位置也是在moov盒子里面。
最后面是一个mdat的盒子,这个就是放多媒体数据的盒子,它占据了mp4文件的绝大部分空间,moov里的chunk的位置偏移offset就是相对于mdat的。
可以用一些现成的工具,如这个在线的MP4Box.js或者是这个MP4Parser,如下图所示,moov里面总共有两个轨道的盒子:
展开视频轨道的子盒子,找到stsz这个盒子,可以看到总共有24帧,每一帧的大小也是可以见到,如下图所示:
还有一个问题,怎么知道这个mp4是h264编码,而不是h265之类的,这个通过avc1盒子可以知道,如下图所示:
Chrome 视频播放过程
我们从多路解复用开始说起,Chrome的多路解复用是在src/media/filters/ffmpeg_demuxer.cc里面进行的,先借助buffer数据初始化一个format_context,记录视频格式信息:
const AVDictionaryEntry* entry =
av_dict_get(format_context->metadata, "creation_time", nullptr, 0);
然后调avformat_find_stream_info得到所有的streams:
// Fully initialize AVFormatContext by parsing the stream a little.
base::PostTaskAndReplyWithResult(
blocking_task_runner_.get(), FROM_HERE,
base::BindOnce(&avformat_find_stream_info, glue_->format_context(),
static_cast<AVDictionary**>(nullptr)),
base::BindOnce(&FFmpegDemuxer::OnFindStreamInfoDone,
weak_factory_.GetWeakPtr()));
一个stream包含一个轨道,循环streams,根据codec_id区分audio、video、text三种轨道,记录每种轨道的数量,设置播放时长duration,用fist_pts初始化播放开始时间start_time:
for (size_t i = 0; i < format_context->nb_streams; ++i) {
AVStream* stream = format_context->streams[i];
const AVCodecParameters* codec_parameters = stream->codecpar;
const AVMediaType codec_type = codec_parameters->codec_type;
const AVCodecID codec_id = codec_parameters->codec_id;
// Skip streams which are not properly detected.
if (codec_id == AV_CODEC_ID_NONE) {
stream->discard = AVDISCARD_ALL;
continue;
}
if (codec_type == AVMEDIA_TYPE_AUDIO) {
// Log the codec detected, whether it is supported or not, and whether or
// not we have already detected a supported codec in another stream.
const int32_t codec_hash = HashCodecName(GetCodecName(codec_id));
base::UmaHistogramSparse("Media.DetectedAudioCodecHash", codec_hash);
if (is_local_file_) {
base::UmaHistogramSparse("Media.DetectedAudioCodecHash.Local",
codec_hash);
}
} else if (codec_type == AVMEDIA_TYPE_VIDEO) {
// Log the codec detected, whether it is supported or not, and whether or
// not we have already detected a supported codec in another stream.
const int32_t codec_hash = HashCodecName(GetCodecName(codec_id));
base::UmaHistogramSparse("Media.DetectedVideoCodecHash", codec_hash);
if (is_local_file_) {
base::UmaHistogramSparse("Media.DetectedVideoCodecHash.Local",
codec_hash);
}
并实例化一个DemuxerStream对象,这个对象会记录视频宽高、是否有旋转角度等,初始化audio_config和video_config,给解码的时候使用。这里面的每一步几乎都是通过PostTask进行的,即把函数当作一个任务抛给media线程处理,同时传递一个处理完成的回调函数。
demuxer_->ffmpeg_task_runner()->PostTask(
FROM_HERE, base::BindOnce(&SetAVStreamDiscard, av_stream(),
enabled ? AVDISCARD_DEFAULT : AVDISCARD_ALL));
如果其中有一步挂了就不会进行下一步,例如遇到不支持的容器格式,在第一步初始化就会失败,就不会调回调函数往下走了。
具体解码是使用ffmpeg的avcodec_send_packet和avcodec_receive_frame进行音视频解码。
解码和解复用都是在media线程处理的。音频解码完成会放到audio_buffer_renderer_algorithm的AudioBufferQueue里面,等待AudioOutputDevice线程读取。为什么起名叫algorithm,因为它还有一个作用就是实现WSOLA语音时长调整算法,即所谓的变速不变调,因为在JS里面我们是可以设置audio/video的playback调整播放速度。
视频解码完成会放到video_buffer_renderer_algorithm.cc的buffer队列里面,这个类的作用也是为了保证流畅的播放体验,包括上面讨论的时钟同步的关系。
准备渲染的时候会先给video_frame_compositor.cc,这个在media里的合成器充当media和Chrome Compositor(最终合成)的一个中介,由它把处理好的frame给最终合成并渲染,Chrome是使用skia做为渲染库,主要通过它提供的Cavans类操作绘图。
Chrome使用的ffmpeg是有所删减的,支持的格式有限,不然的话光是ffmpeg就要10多个MB了。以上就是整体的过程,具体的细节如怎么做音视频同步等,后续再探讨。
更多细节可以直接研究源代码:
https://source.chromium.org/chromium/chromium/src/+/master:media/
Web H265 Player
团队正在自研开发的播放器,基于WASM的H265 Web软解播放器,这里主要的目的是为了:(1)节省视频流量成本;(2)提升视频播放体验/性能
- 浏览器侧对于视频解码的现状瓶颈
**在流媒体领域,长期以来,人们一直在想尽办法提高视频编码的效率,让它在尽可能小的体积内提供最好的画面质量,从而满足人们对于视频传输、存储的需求。
03年5月发布了H264(视频编码格式),13年发布了H265,2020年7月发布了H266标准,目前这个时间点,原生支持H265(HEVC)播放的浏览器极少,可以说基本没有,主要原因是H265的解码有更高的性能要求,从而换取更高的压缩率,目前大多数机器CPU软解H265的超清视频还是有点吃力,硬解兼容性又不好。
- H265与H264相比主要的好处
在有限带宽下传输更高质量的网络视频,仅需原先的一半带宽即可播放相同质量的视频,既极大节约了带宽成本(预计在30%~50%),也大幅提升了用户的观看体验
1、设计思路
本方案使用WASM、FFmpeg、WebGL、Web Audio等实现MP4 H265在Web侧的软解
- WASM(WebAssembly):可以在浏览器里执行原生代码(例如C、C++),需要基于WASM开发可以在浏览器运行的原生代码
- FFmpeg:使用FFmpeg来做解封装(demux)和解码(decoder),主要针对H265编码、MP4封装
- WebGL:H5使用Canvas来绘图,但是默认的2d模式只能绘制RGB格式,使用FFmpeg解码出来的视频数据是YUV格式,想要渲染出来需要进行颜色空间转换,这里使用FFmpeg的libswscale模块进行转换,为了提升性能,使用了WebGL来硬件加速
- Web Audio:FFmpeg解码出来的音频数据是PCM格式,使用H5的Web Audio Api来播放,需要解决音视频同步及杂音问题
- 视频分段加载:根据视频码率和大小,动态分片加载,提升播放性能和流畅度
2、实现方案
核心分为三部分:
- FFmpeg(C + Emscripten 定制 FFmpeg)
- Decoder(C + Emscripten 自研解码模块)
- Player(Typescript 自研Web播放器)
3、预期要解决的问题
CPU占用高,执行线程无响应
- 解决方案:Asyncify方案,在C代码中同步调用异步的JS代码,使用emscripten_sleep代替sleep,同时根据解码耗时动态调整sleep时长
撑爆内存
- 需要对解码进行精细控制,否则容易撑爆内存
音视频同步:视频同步到音频,以音频时间为准
- 视频 - 音频 > 阈值,慢放一定倍数等待
- 视频 - 音频 < 阈值,快放一定倍数追赶
- 倍速播放、动态码率自适应等功能支持
- 不同视频格式(FLV、MP4...)、不同清晰度(1080p、4K...)的软解播放支持,在兼容性和性能上的突破需要持续深耕
总结
流媒体技术领域的探索需要持续突破,包括我们现在内容为王的直播、短视频赛道,欢迎更多有想法、感兴趣的同学,来深入交流,让我们一起探索(Email:shanruo.wj@alibaba-inc.com,微信:shanruo-wj)