後端

無需下載即可讀取 2GB MP4 檔案的中繼資料

MP4 將播放時間和編解碼器資訊保存在一個 moov box 中。此處說明如何透過 HTTP Range 僅擷取該 box,並跳過數 GB 的媒體酬載。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

我們有一個背景工作程式,需要取得數千部上傳影片的時長、解析度和編解碼器,僅此而已。初始版本會完整下載每個檔案,讀取數百位元組的標頭,然後丟棄其餘部分。對於一個 2GB 的影片,這意味著為了得知四個數字而傳輸了 2GB 的資料。這篇文章將展示如何直接透過 HTTP 讀取該標頭,只傳輸幾 KB 而非整個檔案,方法是了解 MP4 的佈局方式,並使用 Range 請求來僅擷取重要的部分。

問題:你需要從 2 gigabytes 中取得 200 bytes

像時長、寬度、高度和編解碼器等元數據,都存放在一個小型的結構化標頭中。實際的音訊和視訊取樣則是剩下的部分,而對於一個長影片來說,剩下的部分幾乎就是全部。一個 2GB 的檔案可能帶有幾 kilobytes 的元數據和 1.999GB 的媒體取樣。

如果你的服務只是偶爾處理一個影片,為了讀取標頭而下載整個檔案是浪費的,但還能承受。如果一個工作叢集正在對數千個上傳檔案進行分類,完整的下載將成為主要的開銷。它會消耗你的物件儲存的出口流量,為了一次大型傳輸而長時間佔用一個連線,並且為一個本應在不到一秒內完成的工作帶來實質的延遲。傳輸時間與工作量不成比例;它與檔案大小成比例,而這正是你不該付出的代價。

目標是讓成本與元數據成比例,而不是媒體內容。要做到這一點,你需要兩樣東西:一份標示元數據在檔案中位置的地圖,以及一種只擷取該區域而不拉取其周圍位元組的方法。

MP4 的 box 佈局方式

一個 MP4 檔案是一連串扁平的 box,有時也稱為 atom。box 是最簡單的容器。它以一個 8 位元組的標頭開始:一個 4 位元組的大端序(big-endian)大小,接著是一個 4 位元組的類型,例如 ftypmoovmdat。這個大小包含了標頭本身,所以一個大小為 8 的 box 是一個只有標頭的空 box。標頭之後是 box 的內容,內容會一直持續到大小用盡為止,然後下一個 box 開始。

在一個典型檔案的最上層,你會看到少數幾個 box:

  • ftyp:檔案類型與相容品牌。很小,且總是在檔案的前面。
  • moov:電影 box。這是元資料(metadata)的容器。時長、軌道列表、解析度、編解碼器參數以及樣本表都存放在裡面。相對於媒體資料,它很小,通常是幾 KB 到幾 MB。
  • mdat:媒體資料。這是音訊和視訊的原始樣本位元組。對於任何實際的影片來說,這佔了檔案的絕大部分。

也可能會有其他的 box,且格式並未固定其順序。有兩個事實讓低成本的解析成為可能。首先,每個 box 都在其標頭中宣告了自己的大小,所以你可以在不讀取當前 box 內容的情況下,找到下一個 box 的起始位置。其次,你想要的元資料完全在 moov 裡面,而 mdat 不包含任何你獲取基本元資料所需的東西。因此,策略不言而喻:讀取 box 標頭,透過算術跳過 mdat,並且只讀取 moov 內的位元組。

使用 Range 請求讀取一個 box 標頭

每一個步驟都取決於能否在一個已知的偏移量上讀取一小段位元組範圍。在 HTTP 中,這就是一個 Range 請求。您請求位元組 N 到 M,而一個支援範圍請求的伺服器會以 206 Partial Content 回應,並只回傳那些位元組。伺服器會用 Accept-Ranges: bytes 標頭來宣告此功能,而像 S3 相容儲存這樣的物件儲存預設就支援它。

這裡有一個輔助函式,用於擷取確切的位元組範圍,以及另一個用於從給定的偏移量讀取並解碼單一 box 標頭的函式:

package mp4

import (
	"encoding/binary"
	"fmt"
	"io"
	"net/http"
)

// fetchRange returns bytes [start, start+length) from url using an HTTP Range request.
func fetchRange(client *http.Client, url string, start, length int64) ([]byte, error) {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	end := start + length - 1
	req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusPartialContent {
		return nil, fmt.Errorf("range not honored, got status %d", resp.StatusCode)
	}
	return io.ReadAll(io.LimitReader(resp.Body, length))
}

type boxHeader struct {
	size       int64 // total box size including the header
	boxType    string
	headerSize int64 // 8 for 32-bit size, 16 for 64-bit size
}

// readBoxHeader reads the 8 byte header at offset, expanding to 16 bytes for a 64-bit size.
func readBoxHeader(client *http.Client, url string, offset int64) (boxHeader, error) {
	buf, err := fetchRange(client, url, offset, 8)
	if err != nil {
		return boxHeader{}, err
	}
	size := int64(binary.BigEndian.Uint32(buf[0:4]))
	boxType := string(buf[4:8])
	return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
}

一次標頭擷取在線路上是 8 位元組,外加 HTTP 的額外負擔。請求和回應標頭的額外負擔,遠大於 8 位元組的本文,因此在實務上,每個 box 標頭您需要付出數百位元組的代價。一個帶有少數幾個頂層 box 的檔案,會產生少數幾個這樣的請求。這就是重點所在:現在傳輸量與 box 的數量成正比,而不是檔案的大小。

透過算術走訪 box 並跳過 mdat

有了標頭讀取器,頂層的走訪就是一個迴圈。在當前偏移量讀取標頭,查看其類型,並決定要做什麼。如果類型是 moov,就表示你找到了元資料,並且可以擷取整個 box 來進行剖析。如果是任何其他類型,包括巨大的 mdat,你完全不需要讀取其內容。你將其大小加到偏移量上,然後移至下一個 box。

// findMoov walks top-level boxes and returns the byte range of the moov box.
// It never reads the contents of mdat or any other box it skips.
func findMoov(client *http.Client, url string, fileSize int64) (start, length int64, err error) {
	var offset int64
	for offset < fileSize {
		h, err := readBoxHeader(client, url, offset)
		if err != nil {
			return 0, 0, err
		}
		if h.size < h.headerSize {
			return 0, 0, fmt.Errorf("invalid box size %d at offset %d", h.size, offset)
		}
		if h.boxType == "moov" {
			return offset, h.size, nil
		}
		// Skip this box entirely. This is the key move: for mdat we jump
		// past gigabytes of media without transferring any of it.
		offset += h.size
	}
	return 0, 0, fmt.Errorf("moov box not found")
}

offset += h.size 這行程式碼就是節省流量的關鍵。當遍歷到一個 1.9GB 的 mdat 時,它不會串流、緩衝或觸碰它。它會讀取 8 位元組的標頭,得知大小,然後將該大小加到偏移量上。下一個請求會落在 mdat 後面的 box 上。媒體資料透過純粹的偏移量算術運算被跳過。

一旦 findMoov 回傳一個範圍,再一個 Range 請求會將整個 moov box 拉入記憶體,你可以在其中解析巢狀的 box 以提取時長和軌道資訊。因為 moov 很小,完整讀取它既便宜又比在其內部進行子範圍請求更簡單。

傳輸的位元組差異是顯著的:

方法 傳輸 2GB 檔案的位元組數 請求次數
完整下載 ~2,000,000,000 1
範圍遍歷,moov 在前端 幾 KB 到低 MB 數 3 到 6
範圍遍歷,moov 在末端 同樣是幾 KB 到低 MB 數 多幾次

範圍遍歷的傳輸量是 moov 的大小加上幾個 box 標頭,無論媒體有多大。檢查一個 200MB 的影片和一個 20GB 的影片,成本大致相同。

關鍵:moov 的位置決定了你的延遲

此格式並未固定 moov 的位置,而其位置會徹底改變遍歷完成的速度。

有些檔案是以 faststart 方式寫入,這意味著 moov 被移到檔案前端,緊接在 ftyp 之後、mdat 之前。這些檔案是為串流而設計的,因為播放器可以立即讀取元數據,而無需跳轉到檔案結尾。對於 faststart 檔案,遍歷過程在第一次或第二次讀取標頭時就能找到 moov,然後就完成了。

其他檔案則將 moov 放在結尾,位於 mdat 之後。這在錄製內容和任何以單次循序寫入的檔案中很常見,因為寫入器在寫完所有樣本之前,並不知道影片框(movie box)的最終大小,所以它會最後才附加 moov。對於這些檔案,頂層遍歷必須越過 mdat 才能到達 moov。好消息是,越過 mdat 仍然只是 offset += h.size,所以它只會多花費一次標頭讀取,而不會下載媒體內容。

有一個值得了解的最佳化方法。如果你的第一個向前步進落在 mdat 上,你可以猜測 moov 在檔案尾部,並直接用一個 Range 請求來擷取檔案的最後一個區塊,然後向後掃描或解析該尾部區域。一個常見的啟發式方法是請求最後約 256KB 的資料,並在那裡尋找 moov 類型。這將一個兩步的遍歷變成單一的尾部擷取。這是一個最佳化,而非必要條件;無論如何,單純的向前遍歷已經避免了下載媒體內容。

唯一真正會花費更多成本的情況是,mdat 的宣告大小為零或延伸到檔案結尾,某些格式用此來表示「直到結尾」。當你看到這種情況時,將檔案的其餘部分視為媒體內容,並切換到尾部策略來定位 moov

處理 64 位元大小與巢狀 box

有兩個細節區分了玩具剖析器與能處理真實檔案的剖析器。

第一個是大型大小。32 位元的大小欄位最大值低於 4GB,這對於大型的 mdat 來說是不夠的。此格式透過一個逸出值來處理這個問題。如果 32 位元的大小等於 1,則實際大小是一個 64 位元的值,儲存在緊接在類型之後的 8 個位元組中,這使得標頭變成 16 個位元組,而不是 8 個。大小為 0 表示該 box 一直延伸到檔案結尾。您的標頭讀取器必須在信任這個 32 位元數字之前檢查這些情況:

func readBoxHeaderFull(client *http.Client, url string, offset, fileSize int64) (boxHeader, error) {
	buf, err := fetchRange(client, url, offset, 16) // read enough for a 64-bit header
	if err != nil {
		return boxHeader{}, err
	}
	size := int64(binary.BigEndian.Uint32(buf[0:4]))
	boxType := string(buf[4:8])

	switch size {
	case 1:
		// 64-bit largesize follows the type field.
		size = int64(binary.BigEndian.Uint64(buf[8:16]))
		return boxHeader{size: size, boxType: boxType, headerSize: 16}, nil
	case 0:
		// Box extends to the end of the file.
		size = fileSize - offset
		return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
	default:
		return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
	}
}

第二個細節是巢狀結構。moov 不是一個葉節點。在它裡面有 mvhd,用於電影標頭,包含總時長和時間刻度;以及每個軌道一個 trak。在每個 trak 內,有一連串的 mdiaminfstbl,最後是 stsd,也就是命名編解碼器的樣本描述。解析巢狀結構遞迴地使用相同的標頭邏輯,但作用於記憶體中的 moov 位元組,而不是透過 HTTP,因為你已經擷取了整個 box。你遍歷子節點的方式與遍歷頂層相同:讀取標頭,根據類型採取行動,再依大小前進。唯一的區別是,像 trak 這樣的容器 box 意味著你要深入其內容,而不是跳過它們。

你不需要在 moov 內部進行範圍請求 (Range-request)。在一次擷取中拉取 moov 的全部意義在於,你想要的一切現在都在本地,而本地解析只是簡單的位元組切片運算。

何時下載整個檔案是更好的選擇

當檔案是遠端的、大型的,且你只需要一小段標頭時,範圍解析是正確的工具。若上述任一條件不符,考量就不同了。

  • 檔案是本地的,或已在記憶體中。沒有可以節省的傳輸。將其開啟並讀取,或交給函式庫處理。範圍邏輯只會增加複雜性。
  • 伺服器不支援 Range。檢查 Accept-Ranges: bytes,或傳送一個範圍請求並確認收到 206 回應。如果你收到 200 回應以及整個檔案內容,表示伺服器忽略了範圍請求,你無論如何都在下載所有內容。某些 CDN 和設定錯誤的來源伺服器會這麼做。
  • 無論如何你都將要讀取檔案的大部分內容。如果下一步是轉碼、為每個場景製作縮圖,或對整個檔案進行雜湊運算,你很快就會需要所有位元組。單獨讀取標頭只會在實際工作開始前增加一次往返。
  • 你需要的元資料不在 moov 中。分段式 MP4(用於自適應串流的類型)將時間資訊分散在整個檔案的 moof box 中。如果你需要的是每個片段的詳細資訊而非頂層摘要,單次標頭擷取無法提供,且存取模式也不同。

對於常見的工作情境,如遠端物件儲存、大型媒體檔案,且只需要影片長度和編解碼器資訊時,Range 遍歷將一個成本與檔案大小成正比的工作,轉變為一個成本與元資料大小成正比的工作。這就是一個能廉價檢查數千個影片的叢集,與另一個將大部分時間和出口流量預算花在傳輸隨即丟棄的位元組上的叢集之間的區別。