[Backend] MP4 串流做法
/ 8 min read
Table of Contents
瀏覽器播放 MP4 時,看起來像在串流,會直覺把它和 m3u8、HLS 放在一起
實際上這兩種做法不是同一件事
這篇想整理的重點是把 MP4 串流到底怎麼播放,和它跟 HLS 的差別
背景
這個主題容易混淆,是因為兩邊表面上都像在做串流播放
播放器都可以邊播邊載
使用者也都可以拖曳進度
但如果從 Network 請求和 server 實作去看,它們的底層模型差很多
尤其在自己寫一個 Node.js 範例時,很容易只看到影片能播,就沒有繼續拆裡面的機制
結果最後只記得一堆零碎名詞,像是 Range、206、Content-Range、m3u8,卻不知道它們分別在解什麼問題
這跟 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 帶
Rangeheader - server 回
206 Partial Content - response 帶
Content-Range - 後面持續出現新的 byte-range request
如何實作 MP4 串流
如果用 Express 實作這種播放方式,整體流程其實可以整理成幾步
先看基本骨架
const app = express();const CHUNK_SIZE = 10 ** 6; // 1MBconst 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 做的事情,大致可以分成五步
- 檢查 request 有沒有帶
Range - 取得影片總大小
- 算出這次要回哪一段 bytes
- 設定
206和相關 response headers - 用
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
- 能正常播放與拖曳,關鍵在
Range、206 Partial Content、Content-Range - 如果
/videoendpoint 回的是206 Partial Content,通常就是 MP4 range streaming
所以這類做法確實有串流體驗,但它不是靠 playlist 與 segment,而是靠 byte-range request
如果從請求流程去看,整件事其實很單純
- client 指定要哪一段 bytes
- server 回那一段 bytes
- 播放器不夠再繼續拿下一段
理解這一點之後,再回頭看 m3u8、HLS 或其他自適應串流方案,就比較不會把不同層級的技術混在一起