天天看點

從 Chrome 源碼 video 實作到 Web H265 Player

作者 | 珊若

首先,我們先來看一張圖:

從 Chrome 源碼 video 實作到 Web H265 Player

在多年以前,我們網頁視訊播放都隻能依賴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 播放完整流程

從 Chrome 源碼 video 實作到 Web H265 Player

是以基于上面的流程,我們現在對于視訊播放首幀的定義就比較清楚了,這裡主要分自動播放、點選播放

從 Chrome 源碼 video 實作到 Web H265 Player
  • 自動播放首幀:loadstart(視訊準備開始請求加載資料)到 timeupdate (播放進度變化)
  • 點選播放首幀:play(使用者點選play)到 timeupdate(播放進度變化)

當然上述流程是在PC的表現,實際上針對這些事件IOS和Android下會有一些差異,這裡不細講。在大緻了解了完整的播放流程後,我們就來重點看下,浏覽器是如何加載和解析多媒體資源的?這裡我們以Chrome為例。

Chromium 多媒體請求和解碼流程

首先,早期Chromium文檔中對于整體的過程是這樣的(目前來看有點過時了,但是核心的原理是一緻的)

從 Chrome 源碼 video 實作到 Web H265 Player

大體來說,由Webkit請求建立一個媒體video标簽,建立一個DOM對象,它會執行個體化一個WebMediaPlayerImpl,這個player是整體的控制中樞,player驅使Buffer去請求多媒體資料,然後交由FFmpeg進行多路解複用和音視訊解碼(FFmpeg是一個開源的第三方音視訊解碼庫),再把解碼後的資料傳給相應的渲染器對象進行渲染繪制,最後讓video标簽去顯示或者聲霸卡進行播放。

現在最新的Chromium Media針對媒體播放管道比較全的概述如下:

從 Chrome 源碼 video 實作到 Web H265 Player

通過上面👆的流程裡,我們可以看到有兩個關鍵問題需要我們重點解析:

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左右,如下圖所示:

從 Chrome 源碼 video 實作到 Web H265 Player

(本地找了個差不多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功能請求相應範圍的位元組數:

從 Chrome 源碼 video 實作到 Web H265 Player

原生播放器的政策隻是使用一個http連接配接加載不同位元組範圍的視訊資料,而視訊網站是每需要一個range的資料的時候就發一個請求。這些請求的響應狀态碼都是206,表示傳回部分内容。如果連接配接斷了或者服務端隻傳回部分資料就關閉連接配接,那麼chrome會重新發個請求。

從 Chrome 源碼 video 實作到 Web H265 Player

另外打開頁面的時候chrome不會提前預加載資料,隻有點選播放了才加載音視步内容,并且preload的buffer size是在加載過程中周期性更新的。

MP4 格式和解複用

這裡重點講一下我們最常見的MP4視訊格式,mp4或稱MPEG-4 Part 14,是一種多媒體容器格式,擴充名為.mp4。

一個視訊可以有3個軌道(Track):視訊、音頻和文本,但是資料的存儲是一維的,從1個位元組到第n個位元組,那麼視訊應該放哪裡,音頻應該放哪裡,mp4/avi等格式做了規定,把視軌音軌合成一個mp4檔案的過程就叫多路複用(mux),而把mp4檔案裡的音視訊軌分離出來就叫多路解複用(demux),這兩個詞是從通信領域來的。

假設現在有個需求,需要取出使用者上傳視訊的第一幀做為封面,這就要求我們去解析mp4檔案,并做解碼。

從 Chrome 源碼 video 實作到 Web H265 Player

上圖是用16進制表示的原始二進制内容,兩個16進制(0000)就表示1個位元組。

mp4是使用box盒子表示它的資料存儲的,标準規定了若幹種盒子類型,每種盒子存放的資料類型不一樣。

從 Chrome 源碼 video 實作到 Web H265 Player

根節點之下,主要包含三個節點: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裡面總共有兩個軌道的盒子:

從 Chrome 源碼 video 實作到 Web H265 Player

展開視訊軌道的子盒子,找到stsz這個盒子,可以看到總共有24幀,每一幀的大小也是可以見到,如下圖所示:

從 Chrome 源碼 video 實作到 Web H265 Player

還有一個問題,怎麼知道這個mp4是h264編碼,而不是h265之類的,這個通過avc1盒子可以知道,如下圖所示:

從 Chrome 源碼 video 實作到 Web H265 Player

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/

從 Chrome 源碼 video 實作到 Web H265 Player

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播放器)
從 Chrome 源碼 video 實作到 Web H265 Player

3、預期要解決的問題

  • CPU占用高,執行線程無響應
    • 解決方案:Asyncify方案,在C代碼中同步調用異步的JS代碼,使用emscripten_sleep代替sleep,同時根據解碼耗時動态調整sleep時長
  • 撐爆記憶體
    • 需要對解碼進行精細控制,否則容易撐爆記憶體
  • 音視訊同步:視訊同步到音頻,以音頻時間為準
    • 視訊 - 音頻 > 門檻值,慢放一定倍數等待
    • 視訊 - 音頻 < 門檻值,快放一定倍數追趕
  • 倍速播放、動态碼率自适應等功能支援
  • 不同視訊格式(FLV、MP4...)、不同清晰度(1080p、4K...)的軟解播放支援,在相容性和性能上的突破需要持續深耕

總結

流媒體技術領域的探索需要持續突破,包括我們現在内容為王的直播、短視訊賽道,歡迎更多有想法、感興趣的同學,來深入交流,讓我們一起探索(Email:[email protected],微信:shanruo-wj)

從 Chrome 源碼 video 實作到 Web H265 Player

繼續閱讀