从 Chrome 源码 video 实现到 Web H265 Player

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 揭秘浏览器视频播放原理。
作者 | 珊若

首先,我们先来看一张图:
image.png
在多年以前,我们网页视频播放都只能依赖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 播放完整流程

image.png
所以基于上面的流程,我们现在对于视频播放首帧的定义就比较清楚了,这里主要分自动播放、点击播放
image.png

  • 自动播放首帧:loadstart(视频准备开始请求加载数据)到 timeupdate (播放进度变化)
  • 点击播放首帧:play(用户点击play)到 timeupdate(播放进度变化)

当然上述流程是在PC的表现,实际上针对这些事件IOS和Android下会有一些差异,这里不细讲。在大致了解了完整的播放流程后,我们就来重点看下,浏览器是如何加载和解析多媒体资源的?这里我们以Chrome为例。

Chromium 多媒体请求和解码流程

首先,早期Chromium文档中对于整体的过程是这样的(目前来看有点过时了,但是核心的原理是一致的)
image.png
大体来说,由Webkit请求创建一个媒体video标签,创建一个DOM对象,它会实例化一个WebMediaPlayerImpl,这个player是整体的控制中枢,player驱使Buffer去请求多媒体数据,然后交由FFmpeg进行多路解复用和音视频解码(FFmpeg是一个开源的第三方音视频解码库),再把解码后的数据传给相应的渲染器对象进行渲染绘制,最后让video标签去显示或者声卡进行播放。

现在最新的Chromium Media针对媒体播放管道比较全的概述如下:
image.png
通过上面👆的流程里,我们可以看到有两个关键问题需要我们重点解析:

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左右,如下图所示:
image.png
(本地找了个差不多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功能请求相应范围的字节数:

image.png
原生播放器的策略只是使用一个http连接加载不同字节范围的视频数据,而视频网站是每需要一个range的数据的时候就发一个请求。这些请求的响应状态码都是206,表示返回部分内容。如果连接断了或者服务端只返回部分数据就关闭连接,那么chrome会重新发个请求。
image.png
另外打开页面的时候chrome不会提前预加载数据,只有点击播放了才加载音视步内容,并且preload的buffer size是在加载过程中周期性更新的。

MP4 格式和解复用

这里重点讲一下我们最常见的MP4视频格式,mp4或称MPEG-4 Part 14,是一种多媒体容器格式,扩展名为.mp4。

一个视频可以有3个轨道(Track):视频、音频和文本,但是数据的存储是一维的,从1个字节到第n个字节,那么视频应该放哪里,音频应该放哪里,mp4/avi等格式做了规定,把视轨音轨合成一个mp4文件的过程就叫多路复用(mux),而把mp4文件里的音视频轨分离出来就叫多路解复用(demux),这两个词是从通信领域来的。

假设现在有个需求,需要取出用户上传视频的第一帧做为封面,这就要求我们去解析mp4文件,并做解码。
image.png
上图是用16进制表示的原始二进制内容,两个16进制(0000)就表示1个字节。

mp4是使用box盒子表示它的数据存储的,标准规定了若干种盒子类型,每种盒子存放的数据类型不一样。

image.png

根节点之下,主要包含三个节点: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里面总共有两个轨道的盒子:
image.png
展开视频轨道的子盒子,找到stsz这个盒子,可以看到总共有24帧,每一帧的大小也是可以见到,如下图所示:
image.png
还有一个问题,怎么知道这个mp4是h264编码,而不是h265之类的,这个通过avc1盒子可以知道,如下图所示:
image.png

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/
image.png

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播放器)

image.png
3、预期要解决的问题

  • CPU占用高,执行线程无响应

    • 解决方案:Asyncify方案,在C代码中同步调用异步的JS代码,使用emscripten_sleep代替sleep,同时根据解码耗时动态调整sleep时长
  • 撑爆内存

    • 需要对解码进行精细控制,否则容易撑爆内存
  • 音视频同步:视频同步到音频,以音频时间为准

    • 视频 - 音频 > 阈值,慢放一定倍数等待
    • 视频 - 音频 < 阈值,快放一定倍数追赶
  • 倍速播放、动态码率自适应等功能支持
  • 不同视频格式(FLV、MP4...)、不同清晰度(1080p、4K...)的软解播放支持,在兼容性和性能上的突破需要持续深耕

总结

流媒体技术领域的探索需要持续突破,包括我们现在内容为王的直播、短视频赛道,欢迎更多有想法、感兴趣的同学,来深入交流,让我们一起探索(Email:shanruo.wj@alibaba-inc.com,微信:shanruo-wj)


image.png

相关文章
|
6月前
|
传感器 小程序 搜索推荐
(源码)java开发的一套(智慧校园系统源码、电子班牌、原生小程序开发)多端展示:web端、saas端、家长端、教师端
通过电子班牌设备和智慧校园数据平台的统一管理,在电子班牌上,班牌展示、学生上课刷卡考勤、考勤状况汇总展示,课表展示,考场管理,请假管理,成绩查询,考试优秀标兵展示、校园通知展示,班级文化各片展示等多种化展示。
94 0
(源码)java开发的一套(智慧校园系统源码、电子班牌、原生小程序开发)多端展示:web端、saas端、家长端、教师端
|
7月前
|
Java 应用服务中间件 测试技术
深入探索Spring Boot Web应用源码及实战应用
【5月更文挑战第11天】本文将详细解析Spring Boot Web应用的源码架构,并通过一个实际案例,展示如何构建一个基于Spring Boot的Web应用。本文旨在帮助读者更好地理解Spring Boot的内部工作机制,以及如何利用这些机制优化自己的Web应用开发。
99 3
|
3月前
|
负载均衡 网络协议 应用服务中间件
web群集--rocky9.2源码部署nginx1.24的详细过程
Nginx 是一款由 Igor Sysoev 开发的开源高性能 HTTP 服务器和反向代理服务器,自 2004 年发布以来,以其高效、稳定和灵活的特点迅速成为许多网站和应用的首选。本文详细介绍了 Nginx 的核心概念、工作原理及常见使用场景,涵盖高并发处理、反向代理、负载均衡、低内存占用等特点,并提供了安装配置教程,适合开发者参考学习。
|
5月前
|
算法 计算机视觉 C++
web 丨 nft 元宇宙链游项目系统开发模式逻辑详细(成熟源码)
一、什么是元宇宙? 元宇宙指的是通过虚拟增强的物理现实,呈现收敛性和物理持久性特征的,基于未来互联网,具有链接感知和共享特征的 3D 虚拟空间。 大概可以从时空性、真实性、独立性、连接性四个方面交叉描述元宇宙:
|
4月前
|
Web App开发 人工智能 iOS开发
灵办AI助手Chrome插件全面评测:PC Web端的智能办公利器
《灵办AI助手:Mac OS下的高效办公利器》 灵办AI助手是一款专为提升工作效率而设计的浏览器插件,适用于Chrome、Edge等主流浏览器,在Mac OS系统中表现尤其出众。本文将深入评测其核心功能,包括网页翻译、AI对话、AI阅读及代码辅助等,展示如何在实际工作中运用这些功能来提升效率。此外,文中还提供了详细的安装与设置指南,帮助读者轻松上手这款办公神器。无论你是学生、职场人还是开发者,灵办AI助手都能成为你提高生产力的理想选择。
135 0
|
5月前
|
Web App开发
软件开发常见流程之移动端调试方法,利用Chrome(谷歌浏览器)的模拟手机调试,搭建本地Web服务器,手机和服务器在一个局域网,通过手机访问服务器,使用服务器,利用ip实现域名访问
软件开发常见流程之移动端调试方法,利用Chrome(谷歌浏览器)的模拟手机调试,搭建本地Web服务器,手机和服务器在一个局域网,通过手机访问服务器,使用服务器,利用ip实现域名访问
|
6月前
|
JavaScript 前端开发 Java
基于SpringBoot+Vue+uniapp的在线开放课程的Web前端的详细设计和实现(源码+lw+部署文档+讲解等)
基于SpringBoot+Vue+uniapp的在线开放课程的Web前端的详细设计和实现(源码+lw+部署文档+讲解等)
|
6月前
|
中间件 Java 生物认证
Web应用&源码泄漏&开源闭源&指纹识别&GIT&SVN&DS&备份
Web应用&源码泄漏&开源闭源&指纹识别&GIT&SVN&DS&备份
|
6月前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp小程序的高校疫情防控web系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp小程序的高校疫情防控web系统附带文章源码部署视频讲解等
36 0
|
7月前
|
移动开发 前端开发 JavaScript
10款精美的web前端源码的特效,2024年最新面试题+笔记+项目实战
10款精美的web前端源码的特效,2024年最新面试题+笔记+项目实战