iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
JavaScript

Learn HTTP With JS(2)系列 第 29

m3u8 與 HTTP Live Streaming (HLS) 解析:preload、字幕與動態畫質切換教學

  • 分享至 

  • xImage
  •  

前言

某天在 聯合影音網 看到了影片的 HTML 標籤

<video
  class="ui-video"
  width="100%"
  height="100%"
  preload="metadata"
  playsinline=""
  webkit-playsinline=""
  muted=""
  src="https://cdn.udn.com/vods3/251221183729786/playlist.m3u8"
>
  <track
    src="//video.udn.com/api-player/video-subtitle/news/1316728"
    default="true"
  />
</video>

其中 preload="metadata"m3u8<track> 都是我沒看過的,就趁這個機會來研究看看吧!

防雷

由於 iThome 使用 cloudflare,發文若有一些 XSS 的關鍵字會被擋下,所以本文若有用到 script 標籤,都會改成 <xcript>

Syntax of preload

<video preload="none"></video>
<video preload="metadata"></video>
<video preload="auto"></video>

preload="none"

這個應該沒有爭議,就是 media 開始播放,才會發起 HTTP request 載入資源

preload="auto"

告訴瀏覽器 "可以開始下載資源,即便使用者還沒按播放"

這邊要注意的是,不同瀏覽器的實作方式不同,以 Chrome 瀏覽器為例,一開始只會先下載幾 MB 的資料

<video
  muted
  controls
  playsinline
  preload="auto"
  width="100%"
  height="100%"
  src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
></video>

preload-auto-chrome-1.5s

之後使用者開始播放影片後,才會接收後續的資源

preload-auto-chrome-keep-connection

在資源完整接收完之前,都會顯示 CAUTION: request is not finished yet!

上述情境,僅限於沒有跳轉影片的情況

若有跳轉,則會發起新的 Range Request

總結來說,preload="auto" 並沒有強制瀏覽器要馬上下載 "整個" 資源

firebase/storagegetMetadata

我很好奇所謂的 metadata 有哪些

我第一個想到的是 firebase/storagegetMetadata 這個方法,順手實測看看

import { initializeApp, FirebaseOptions } from "firebase/app";
import { getStorage, ref, getMetadata } from "firebase/storage";

const firebaseOptions: FirebaseOptions = {
  apiKey: process.env.apiKey,
  authDomain: process.env.authDomain,
  databaseURL: process.env.databaseURL,
  projectId: process.env.projectId,
  storageBucket: process.env.storageBucket,
  messagingSenderId: process.env.messagingSenderId,
  appId: process.env.appId,
  measurementId: process.env.measurementId,
};
const firebaseApp = initializeApp(firebaseOptions);
const firebaseStorage = getStorage(firebaseApp);
const storageRef = ref(firebaseStorage, "20230519/20230519_123754.jpg");
getMetadata(storageRef).then(console.log);

結果

{
  "type": "file",
  "bucket": "xxxx-xxxxxx-xx.appspot.com",
  "generation": "1726576002013836",
  "metageneration": "4",
  "fullPath": "20230519/20230519_123754.jpg",
  "name": "20230519_123754.jpg",
  "size": 4312957,
  "timeCreated": "2024-09-17T12:26:42.015Z",
  "updated": "2024-11-13T12:35:07.690Z",
  "md5Hash": "wkMu9YKP/eCW/BCsvgfRkg==",
  "contentEncoding": "identity",
  "contentLanguage": "en",
  "contentType": "image/jpeg"
}

metadata 的中文很難解釋,如果以上述例子來看,就是 "描述這張圖片" 的資料

preload="metadata"

有了 metadata 的概念,我們接著來實測 preload="metadata" 到底包含哪些屬性

基本的 HTML

<video
  preload="metadata"
  width="100%"
  height="100%"
  src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
></video>

寫個 JS 印出 loadedmetadata 前後的 HTMLVideoElement 差異

const videoEl = document.getElementsByTagName("video")[0];
const oldVideoEl = {};
for (const attr in videoEl) {
  oldVideoEl[attr] = videoEl[attr];
}
console.log(oldVideoEl);

videoEl.addEventListener("loadedmetadata", (e) => {
  const newVideoEl = {};
  for (const attr in videoEl) {
    newVideoEl[attr] = videoEl[attr];
  }
  console.log(newVideoEl);
});

使用 git 比較 oldVideoElnewVideoEl 的差異

video-preload-attrs

可以得知 video 的 metadata "至少" 包含以下屬性

{
  "videoWidth": 1280,
  "videoHeight": 720,
  "duration": 653.804263
}

實際上觀察瀏覽器的 Network,瀏覽器只會先載入部分 mp4 的內容,且載入的資料量明顯比 preload="auto" 還要少

preload-metadata-not-load-all-content

<track>

再來談到 <track>,可以幫影片或音樂加上字幕,具體使用方法為

<video src="video.mp4">
  <track default srclang="en" label="English" src="subtitle-en.vtt" />
  <track srclang="zh-TW" label="繁體中文" src="subtitle-zh-TW.vtt" />
</video>

根據 HTMLMediaElement: loadedmetadata 的描述,字幕也在 preload="metadata" 的範圍內

寫個 PoC 來驗證:

  1. Node.js http.Server
import { readFileSync } from "fs";
import http from "http";
import { join } from "path";

const httpServer = http.createServer((req, res) => {
  const url = new URL(req.url || "/", "http://localhost:5000");
  if (url.pathname === "/") {
    const filePath = join(import.meta.dirname, "index.html");
    const nonSharedBuffer = readFileSync(filePath);
    res.setHeader("Content-Type", "text/html");
    res.end(nonSharedBuffer);
    return;
  }
  if (url.pathname === "/copy-from-mdn.vtt") {
    const filePath = join(import.meta.dirname, "copy-from-mdn.vtt");
    const nonSharedBuffer = readFileSync(filePath);
    res.setHeader("Content-Type", "text/vtt");
    res.end(nonSharedBuffer);
    return;
  }
  if (url.pathname === "/ElephantsDream.mp4") {
    const filePath = join(import.meta.dirname, "../range/ElephantsDream.mp4");
    const nonSharedBuffer = readFileSync(filePath);
    res.setHeader("Content-Type", "video/mp4");
    res.end(nonSharedBuffer);
    return;
  }
});
httpServer.listen(5000);
  1. index.html
<video
  muted
  controls
  playsinline
  preload="metadata"
  width="100%"
  height="100%"
  src="ElephantsDream.mp4"
>
  <track default srclang="en" label="English" src="copy-from-mdn.vtt" />
</video>

實測確實會預先載入字幕

preload-metadata-include-vtt

vtt

vtt 全名是 Web Video Text Tracks Format (WEBVTT),是一個純文本的格式

webvtt-pure-text

srt

說到字幕,我第一個想到的是 srt,也是純文本的格式

1
00:00:01,000 --> 00:00:04,000
這是第一行字幕。

2
00:00:05,000 --> 00:00:07,000
這是第二行字幕。

3
00:00:08,000 --> 00:00:10,000
這是第三行字幕。

m3u8 簡介

  • 前身是 MP3 URL (m3u)
  • 這是一個純文本的內容
  • 可以列出音樂或影片的所在位置
  • 主要作用是,媒體播放器知道要播放哪些檔案
  • m3u 已經很少使用了,現在基本上都是用 m3u8
  • m3u8 的 8 是指 UTF-8
  • RFC 8216: HTTP Live Streaming 跟 m3u8 的關係密不可分
  • 核心概念:將不同畫質的影片切分成多段影片,方便快取、在網速波動的情況動態調整畫質
  • RFC 8216 是蘋果提出的(推測是為了解決 iPhone 手機在網路不穩的情況聽音樂、看影片,又要保持最佳的使用者體驗)
  • Content-Type: application/vnd.apple.mpegurl

觀察 <video> + m3u8 是如何載入影片

我們以 https://cdn.udn.com/vods3/251221183729786/playlist.m3u8 為範例

  1. 首先,瀏覽器會載入 playlist.m3u8
    m3u8-apply-diff-resolution

  2. 接著,瀏覽器會有一套機制,選擇出對應畫質(下圖選的是 1920x1080)的 chunklist_b4628000.m3u8
    browser-choose-1920-chunklist

  3. 最後,就會根據 chunklist_b4628000.m3u8 回傳的內容,載入對應的 ts 影片
    browser-load-ts-chunks

m3u8 vs HTTP Range Request

假設今天你收到一個需求,要在網頁上呈現一個 10 分鐘的影片,並且要確保跨裝置的用戶都能獲得最好的使用者體驗

總共有三個影片素材

  • video_480P.mp4
  • video_720P.mp4
  • video_1080P.mp4

你很開心的寫了一版 HTML,根據使用者的裝置寬度,來決定要載入哪個畫質的影片

<video src="video_480P.mp4" v-if="width <= 600"></video>
<video src="video_720P.mp4" v-if="width > 600 && width <= 1024"></video>
<video src="video_1080P.mp4" v-if="width > 1024"></video>

結果發現用手機觀看畫質超爛...

接著你開始研究

但你發現,最終也只能決定 "初始載入的影片畫質",如果今天在移動的火車上,或是剛好搭電梯,都會遇到網路不穩定的狀況

為什麼我們平常看影片的平台(YouTube, Netfilx...)都可以做到動態切換畫質呢?其中一個原因就是 m3u8!

直接來張圖比較 m3u8 跟傳統 HTTP range request + 大型 mp4 檔案的差別
m3u8-vs-http-range-request

另外,由於 m3u8 是 server 決定要把影片切成幾等份,所以檔名是固定的,非常適合快取!

雖然 HTTP range request 也可以快取,但不同瀏覽器發送 Range 的策略不同

  • Range: bytes=0-1
  • Range: bytes=0-100
  • Range: bytes=0-1000

都會被視為不同的 Cache Key,所以快取命中的機率就很低,也很難預先把 Cache 準備好

如何支動態切換畫質

實測不同瀏覽器搭配 HTML 原生的 <video src="playlist.m3u8">,並且在中途使用瀏覽器內建功能切換網速

  • Chrome 不支援動態切換畫質(即便切到 3G,還是繼續載入 1080P 的第二個 chunk)
    chrome-does-not-change-resolution-in-video
  • Firefox 不支援 <video src="playlist.m3u8">
    firefox-does-not-implement-m3u8

想依賴瀏覽器原生的機制來處理 m3u8 是不可靠的,所以這時候就需要專門的套件

要怎麼啟用 HTTP Live Streaming (HLS)

很幸運的是,JavaScript 生態系有個 hls.js 套件,幫我們處理好細節了

只需要以下 HTML

<xcript src="https://cdn.jsdelivr.net/npm/hls.js@1"></xcript>
<video id="video" width="100%" height="100%"></video>
<xcript>
  if (Hls.isSupported()) { const video =
  document.getElementsByTagName("video")[0]; const hls = new Hls();
  hls.loadSource("https://cdn.udn.com/vods3/260105182347770/playlist.m3u8");
  hls.attachMedia(video); }
</xcript>

就可以根據網速,動態載入不同畫質的影片
hls-js

小結

在這篇文章,我們學到了

  • preload 的三種策略:none, auto, metadata
  • 字幕相關:<track>, vtt, srt
  • HTTP Live Streaming (HLS) + m3u8

其實還有很多可以深入,包含

  • 付費的影音串流平台(Netfilx, HamiVideo...)是怎麼保護影片資源只能給訂戶觀看
  • HLS 不一定要把影片切成多個 ts!?#EXT-X-BYTERANGE 的功用
  • 深入理解 RFC 8216: HTTP Live Streamimg
  • hls.js 是用哪些 API 實現的

礙於篇幅關係,若未來有機會再來研究上述這些題目~

參考資料


上一篇
介紹 ALPN 如何讓 client 與 server 在 TLS 階段協商 HTTP/1.1 或 HTTP/2,並以 curl、Node.js 實例解析
系列文
Learn HTTP With JS(2)29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言