skip to content
BlogZzz

[Backend] MP4 串流做法

/ 8 min read

Table of Contents

瀏覽器播放 MP4 時,看起來像在串流,會直覺把它和 m3u8、HLS 放在一起

實際上這兩種做法不是同一件事

這篇想整理的重點是把 MP4 串流到底怎麼播放,和它跟 HLS 的差別

背景

這個主題容易混淆,是因為兩邊表面上都像在做串流播放

播放器都可以邊播邊載

使用者也都可以拖曳進度

但如果從 Network 請求和 server 實作去看,它們的底層模型差很多

尤其在自己寫一個 Node.js 範例時,很容易只看到影片能播,就沒有繼續拆裡面的機制

結果最後只記得一堆零碎名詞,像是 Range206Content-Rangem3u8,卻不知道它們分別在解什麼問題

這跟 m3u8 / HLS 差在哪

MP4 + HTTP Range 的做法,本質上還是在讀同一個檔案,只是每次不拿完整內容,而是拿其中一段 bytes

HLS 則不是這樣

HLS 通常會先下載一份 m3u8 playlist,再依照清單抓一段一段的媒體檔

那些 segment 可能是 .ts,也可能是 fragmented MP4

白話一點看

  • MP4 range 是對同一個檔案切範圍
  • HLS 是先拿播放清單,再拿很多小檔案

所以如果在 /video endpoint 看到 206 Partial Content,大多數情況不是 HLS,而是比較單純的 MP4 range streaming

瀏覽器實際在做什麼

瀏覽器播放 MP4 時,通常不是一開始就把整支影片完整抓完

<video> 開始載入影片,request 很常先帶這種 header

Range: bytes=0-

意思是先從第 0 個 byte 開始拿資料

server 收到之後,也不會直接把整支影片一次回完,而是只回其中一段,並用 206 Partial Content 告訴 client 這次拿到的是部分內容

這樣播放器就可以一邊收資料、一邊 decode、一邊播放

如果使用者把進度條往後拖,瀏覽器也可能直接改要另一段範圍

這也是 seek 能成立的原因,因為 server 願意接受任意位置的 byte-range request

所以從 Network 看,常見的畫面會是這樣

  • client 帶 Range header
  • server 回 206 Partial Content
  • response 帶 Content-Range
  • 後面持續出現新的 byte-range request

如何實作 MP4 串流

如果用 Express 實作這種播放方式,整體流程其實可以整理成幾步

先看基本骨架

const app = express();
const CHUNK_SIZE = 10 ** 6; // 1MB
const HTML_PATH = path.join(__dirname, "index.html");
const VIDEO_PATH = path.join(__dirname, "what-the-dog-doing2.mp4");
app.get("/", function (req, res) {
res.sendFile(HTML_PATH);
});
app.get("/video", function (req, res) {
// ...
});

這支程式的定位很單純

  • 它不是影音平台
  • 它沒有轉碼流程
  • 它沒有分段檔
  • 它只是用 Express 提供頁面與一支 MP4

/video 這個 route 做的事情,大致可以分成五步

  1. 檢查 request 有沒有帶 Range
  2. 取得影片總大小
  3. 算出這次要回哪一段 bytes
  4. 設定 206 和相關 response headers
  5. fs.createReadStream() 把指定區段送出去

第一步通常是先檢查 Range

app.get("/video", function (req, res) {
const rangeHeader = req.headers.range;
if (!rangeHeader) {
return res.status(400).send("Requires Range header");
}

這個 endpoint 不是拿來做整檔下載,所以沒有 Range 時直接拒絕,語意會比較清楚

第二步是取得檔案大小

const videoStats = getVideoMetadata();
const videoSize = videoStats.size;

因為後面回傳 partial content 時,必須把範圍描述清楚

Content-Range: bytes start-end/total

如果不知道整個檔案有多大,就沒辦法正確組出這個 header

第三步是 parse Range

很多教學會直接用字串把數字抽出來,但只要輸入稍微不符合預期,就很容易踩到邊界問題

把 parsing 抽成獨立函式通常比較穩

function parseRangeHeader(rangeHeader, videoSize) {
const matches = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader);
const start = Number(matches[1]);
const requestedEnd = matches[2] ? Number(matches[2]) : start + CHUNK_SIZE - 1;
const end = Math.min(requestedEnd, start + CHUNK_SIZE - 1, videoSize - 1);
if (Number.isNaN(start) || Number.isNaN(end) || start >= videoSize || start > end) {
return null;
}
}

這裡主要是在處理幾種常見問題

  • 起點超過檔案大小
  • start > end
  • 結束位置超過檔案尾端
  • client 一次要求太大段內容

第四步是設定 response headers

res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${videoSize}`,
"Accept-Ranges": "bytes",
"Content-Length": contentLength,
"Content-Type": "video/mp4",
});

這裡最重要的是三件事

  • 狀態碼是 206 Partial Content
  • Content-Range 說明這次回的是哪一段
  • Accept-Ranges: bytes 告訴 client 這個資源支援 byte range

如果範圍不合法,通常也會直接回 416

res.set("Content-Range", `bytes */${videoSize}`);
return res.status(416).send("Invalid Range header");

最後一步才是把資料真正送出去

const videoStream = fs.createReadStream(VIDEO_PATH, { start, end });
videoStream.pipe(res);

這種方式不需要先把整支影片完整載進記憶體

它可以從磁碟讀出指定區段,再一路寫到 response

這也是為什麼這類場景很自然會用 stream

另外也常看到 server 會限制單次 chunk 大小

const CHUNK_SIZE = 10 ** 6;

就算 client 沒有指定結束位置,server 也不一定要把剩下全部資料一次送完

這麼做有幾個實際原因

  • 單次 response 不會太大
  • I/O 比較容易控制
  • 播放器可以持續往後拿資料

結論

  • MP4 + HTTP Range 不是 HLS
  • 它不是先抓 playlist 再抓很多 segment
  • 它是對同一個 MP4 檔案反覆發出 byte-range request
  • 能正常播放與拖曳,關鍵在 Range206 Partial ContentContent-Range
  • 如果 /video endpoint 回的是 206 Partial Content,通常就是 MP4 range streaming

所以這類做法確實有串流體驗,但它不是靠 playlist 與 segment,而是靠 byte-range request

如果從請求流程去看,整件事其實很單純

  1. client 指定要哪一段 bytes
  2. server 回那一段 bytes
  3. 播放器不夠再繼續拿下一段

理解這一點之後,再回頭看 m3u8、HLS 或其他自適應串流方案,就比較不會把不同層級的技術混在一起