バックエンド

2GBのファイルをダウンロードせずにMP4メタデータを読み取る

MP4は、再生時間とコーデック情報を`moov`ボックスに保持しています。ここでは、`HTTP Range`を使用してそのボックスのみをフェッチし、ギガバイト単位のメディアペイロードをスキップする方法を説明します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

アップロードされた数千の動画について、その再生時間、解像度、コーデックのみを必要とするワーカーがありました。単純な実装では、各ファイルを完全にダウンロードし、数百バイトのヘッダーを読み込んで、残りは破棄していました。2GBの動画の場合、4つの数値を知るために2GBの転送が必要ということになります。本稿では、MP4のレイアウトを理解し、Rangeリクエストを使って重要な部分だけをフェッチすることで、ファイル全体ではなく数キロバイトを転送し、HTTP経由でそのヘッダーを直接読み込む方法を紹介します。

問題点: 2ギガバイトの中から200バイトが必要

再生時間、幅、高さ、コーデックなどのメタデータは、小さな構造化されたヘッダーに格納されています。実際の音声および映像サンプルが残りの部分であり、長時間の動画では、その残りの部分がほぼすべてを占めます。2GBのファイルには、数キロバイトのメタデータと1.999GBのメディアサンプルが含まれている可能性があります。

サービスがたまに1つの動画を処理する程度であれば、ヘッダーを読むために全体をダウンロードするのは無駄ですが、許容範囲内です。ワーカーフリートが何千ものアップロードを分類している場合、完全なダウンロードがすべてを占有してしまいます。それはオブジェクトストレージからのエグレスを消費し、大規模な転送の間、接続を開いたままにし、1秒未満で完了するはずのジョブの前に実質的なレイテンシーを生じさせます。転送時間は作業量に比例しません。ファイルサイズに比例しますが、これはコストを支払う対象としては間違っています。

目標は、コストをメディアではなくメタデータに比例させることです。そのためには、2つのことが必要です。ファイル内のどこにメタデータが存在するかを示すマップと、その周辺のバイトを取得することなくその領域だけをフェッチする方法です。

MP4がボックスとしてどのようにレイアウトされているか

MP4ファイルは、ボックス(アトムと呼ばれることもあります)のフラットなシーケンスです。ボックスは、最も単純なコンテナです。ボックスは8バイトのヘッダーで始まります。これは4バイトのビッグエンディアンのサイズと、それに続くftypmoovmdatなどの4バイトのタイプで構成されます。サイズにはヘッダー自体も含まれるため、サイズが8のボックスはヘッダーのみを持つ空のボックスです。ヘッダーの後にはボックスのコンテンツが続き、サイズを使い切ると次のボックスが始まります。

一般的なファイルのトップレベルには、少数のボックスが見られます。

  • ftyp: ファイルタイプと互換性のあるブランド。サイズは小さく、常に先頭近くにあります。
  • moov: ムービーボックス。これはメタデータコンテナです。再生時間、トラックリスト、解像度、コーデックパラメータ、サンプルテーブルなどがすべてこの中に格納されています。メディアデータに比べてサイズは小さく、通常はキロバイトから数メガバイト程度です。
  • mdat: メディアデータ。これはオーディオとビデオの生のサンプルバイトです。実際のビデオファイルでは、これがファイルの大部分を占めます。

他のボックスが存在することもあり、その順序はフォーマットによって固定されていません。低コストな解析を可能にする2つの事実があります。第一に、すべてのボックスはヘッダーで自身のサイズを宣言しているため、現在のボックスのコンテンツを読み取ることなく次のボックスの開始位置を見つけることができます。第二に、必要なメタデータはすべてmoov内にあり、mdatには基本的なメタデータに必要なものは何も含まれていません。したがって、戦略は自ずと決まります。ボックスヘッダーを読み取り、計算によってmdatをスキップし、moov内のバイトのみを読み取るのです。

Rangeリクエストによる1つのボックスヘッダーの読み取り

すべてのステップは、既知のオフセットにある小さなバイト範囲を読み取れることに依存します。HTTP経由では、それはRangeリクエストになります。バイトNからMまでを要求すると、Rangeをサポートするサーバーは206 Partial Contentで応答し、そのバイトのみを返します。サーバーはAccept-Ranges: bytesヘッダーでこれを通知し、S3互換ストレージのようなオブジェクトストアはデフォルトでこれをサポートしています。

ここに、正確なバイト範囲をフェッチするヘルパーと、指定されたオフセットから単一のボックスヘッダーを読み取ってデコードするヘルパーがあります。

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
}

1回のヘッダーフェッチは、ネットワーク上で8バイトに加えてHTTPオーバーヘッドがかかります。リクエストヘッダーとレスポンスヘッダーのオーバーヘッドは8バイトのボディをはるかに上回るため、実際にはボックスヘッダーごとに数百バイトのコストがかかります。少数のトップレベルボックスを持つファイルでは、これらのリクエストが少数発生します。それが要点です。転送量はファイルのサイズではなく、ボックスの数に比例するようになるのです。

算術演算によるボックスのウォークとmdatのスキップ

ヘッダーリーダーがあれば、トップレベルのウォークはループになります。現在のオフセットでヘッダーを読み込み、タイプを見て、何をすべきかを決定します。それがmoovであれば、メタデータを見つけたことになり、そのボックス全体をフェッチして解析できます。巨大なmdatを含め、それ以外のものであれば、その内容は一切読み込みません。そのサイズをオフセットに加算し、次のボックスに移動します。

// 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 に続くボックスに到達します。メディアデータは、オフセットに対する純粋な算術演算としてスキップされます。

findMoov が範囲を返すと、もう1つのRangeリクエストが moov ボックス全体をメモリに読み込み、そこでネストされたボックスを解析してデュレーションとトラック情報を抽出します。moov は小さいため、その内部でサブレインジするよりも、全体を読み込む方が安価で簡単です。

転送されるバイト数の違いは歴然です。

アプローチ 2GBファイルの転送バイト数 リクエスト数
フルダウンロード ~2,000,000,000 1
Rangeウォーク、moovが前方にある場合 数KBから数MB前半 3~6
Rangeウォーク、moovが末尾にある場合 同様に数KBから数MB前半 数回追加

Rangeウォークの転送量は、メディアのサイズに関係なく、moov のサイズにいくつかのボックスヘッダーを加えたものになります。200MBのビデオと20GBのビデオを調査するコストは、ほぼ同じです。

注意点: moovの配置がレイテンシを決定する

フォーマットはmoovの配置場所を固定しておらず、その配置がウォークの完了速度のすべてを左右します。

一部のファイルはfaststartで書き込まれます。これは、moovが先頭、つまりftypの直後でmdatの前に移動されることを意味します。これらのファイルはストリーミング用に作られています。なぜなら、プレーヤーが末尾までシークすることなく即座にメタデータを読み取れるからです。faststartファイルの場合、ウォークは最初か2番目のヘッダー読み取りでmoovを見つけ、それで完了です。

他のファイルでは、moovは末尾、つまりmdatの後に置かれます。これは録画や、単一のシーケンシャルパスで書き込まれるあらゆるものによく見られます。なぜなら、ライターはすべてのサンプルを書き終えるまでムービーボックスの最終的なサイズがわからないため、最後にmoovを追加するからです。これらのファイルの場合、トップレベルのウォークはmoovに到達するためにmdatをステップオーバーする必要があります。幸いなことに、mdatのステップオーバーは依然として単なるoffset += h.sizeであるため、メディアのダウンロードではなく、追加のヘッダー読み取りが1回発生するだけです。

知っておく価値のある最適化があります。最初のフォワードステップがmdatに到達した場合、moovが末尾にあると推測し、最後のチャンクに対してRangeリクエストを発行してファイルの最後の部分を直接取得し、その後、後方スキャンまたはその末尾領域のパースを行うことができます。一般的なヒューリスティックは、最後の256KB程度をリクエストし、そこでmoovタイプを探すことです。これにより、2ステップのウォークが単一の末尾フェッチに変わります。これは最適化であり、必須ではありません。通常のフォワードウォークでも、いずれにせよメディアのダウンロードは回避されます。

本当により多くのコストがかかる唯一のケースは、宣言されたサイズがゼロであるか、ファイルの末尾まで拡張されているmdatです。これは一部のフォーマットで末尾まで続くことを意味するために使用されます。それを見つけた場合は、ファイルの残りの部分をメディアとして扱い、moovを見つけるために末尾戦略に切り替えてください。

64ビットサイズとネストされたボックスの扱い

おもちゃのパーサーと、実際のファイルに対応できるパーサーとを分ける2つの詳細があります。

1つ目は大きなサイズです。32ビットのサイズフィールドは4GB未満で最大値に達しますが、これは大きなmdatには十分ではありません。このフォーマットでは、エスケープ値を使ってこれを処理します。32ビットのサイズが1の場合、実際のサイズはtypeの直後の8バイトに格納された64ビット値となり、ヘッダーは8バイトではなく16バイトになります。サイズが0の場合は、ボックスがファイルの終わりまで続くことを意味します。ヘッダーリーダーは、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
	}
}

2つ目の詳細はネスト構造です。moovはリーフではありません。その内部には、全体のデュレーションとタイムスケールを持つムービーヘッダーであるmvhdと、トラックごとのtrakが配置されています。各trakの内部には、mdiaminfstbl、そして最後にコーデック名を指定するサンプル記述であるstsdという一連のボックスがあります。ネスト構造の解析では、再帰的に同じヘッダーロジックを使用しますが、ボックス全体をすでにフェッチしているため、HTTP経由ではなく、メモリ内のmoovのバイトに対して行います。トップレベルを走査したのと同じ方法で子を走査します。つまり、ヘッダーを読み取り、タイプに基づいて処理を行い、サイズ分だけ進みます。唯一の違いは、trakのようなコンテナボックスの場合、その内容をスキップするのではなく、その中へと降りていくという点です。

moovの内部でRangeリクエストを行う必要はありません。moovを一度のフェッチで取得する要点は、必要なものすべてがローカルに存在し、ローカルでの解析は簡単なバイトスライスの算術演算になるということです。

ファイル全体をダウンロードする方が良い場合

Range解析は、ファイルがリモートにあり、サイズが大きく、小さなヘッダーだけが必要な場合に適切なツールです。これらの条件のいずれかが逆になると、その判断は変わります。

  • ファイルがローカルにあるか、すでにメモリ上にある場合。節約すべき転送はありません。ファイルを開いて読み込むか、ライブラリに渡してください。Rangeのロジックは複雑さを増すだけです。
  • サーバーがRangeをサポートしていない場合。Accept-Ranges: bytes を確認するか、Rangeを送信して 206 レスポンスを確認してください。もしボディ全体とともに 200 を受け取った場合、サーバーはRangeを無視しており、結局はすべてをダウンロードしていることになります。一部のCDNや設定が間違っているオリジンサーバーがこれを行います。
  • いずれにせよファイルの大部分を読み込む予定である場合。次のステップがトランスコーディング、全シーンのサムネイル作成、またはファイル全体のハッシュ化である場合、すぐにすべてのバイトを取得することになります。ヘッダーを個別に読み込むことは、実際の作業の前に追加のラウンドトリップを増やすだけです。
  • 必要なメタデータが moov の中にない場合。アダプティブストリーミングで使用される種類のフラグメント化MP4は、タイミング情報をファイル全体の moof ボックスに分散させます。トップレベルの要約ではなく、フラグメントごとの詳細が必要な場合、一度のヘッダー取得ではそれを手に入れることはできず、アクセスパターンも異なります。

一般的なワーカーのケース、つまりリモートオブジェクトストレージ、大きなメディアファイル、そして再生時間とコーデックのみが必要な場合、Rangeウォークは、コストがファイルサイズに比例していたジョブを、コストがメタデータサイズに比例するジョブに変えます。これは、何千ものビデオを安価に検査できるフリートと、時間と下り(egress)の予算のほとんどを、すぐに破棄するバイトの移動に費やすフリートとの違いです。