ある日、メディア処理パイプラインの一部が公開まで進まなくなりました。原因として出ていたのは、最初は冗談のようにも見えるエラーでした。
Invalid CFR packet duration: 5580 ticks, expected 5625
デコーダが落ちたわけではありません。FFmpegはH.264ファイルを生成していました。止めたのはエンコード後の検証処理です。出力はCFRであるという契約なのに、1つのパケットだけ、私が設計した時間グリッドから外れていました。
処理を再実行しても5580。もう一度実行しても5580。後には別のソースでも同じ値が出ました。この反復は重要でした。ネットワークの一時障害やまれな競合状態ではなく、同じ入力と処理から再現する決定論的な不整合だったからです。
その後、見た目は似ていても性質の違う2つ目の問題を見つけました。24 fps、ビデオトラックのタイムスケールが90,000なら、通常のサンプルの継続時間は正確に3750 tickになるはずです。ところが検証処理は3751を検出しました。
この2件を「FFmpegの丸め誤差」でひとまとめにするのは不正確です。5580と5625の差は45 tick、ちょうど0.5 msです。一方、3751と3750の差は1 tick、約11.1マイクロ秒にすぎません。
この2つの不具合で、それまで「FPS」という言葉の中で曖昧に混ぜていたものを分けて考えるようになりました。フレームレート、タイムベース、PTS/DTS、パケットの継続時間です。
CFRは「16 fps」という表示名ではない
今の私が生成ファイルをCFRと呼ぶとき、ffprobeが16/1や24/1と表示するだけでは不十分です。
このパイプラインでのCFRは、もっと具体的な契約です。表示時刻が等間隔のグリッド上にあり、通常の動画サンプルの継続時間がそのグリッドの1ステップと一致することを意味します。
16 fpsなら1フレームの時間は次の通りです。
1 / 16 = 0.0625 s = 62.5 ms
ビデオトラックを90,000 tick/sにすると、同じ時間を整数で正確に表せます。
90000 / 16 = 5625 ticks
つまり5625は検証処理に埋め込んだ都合のいい定数ではありません。16 fpsと90,000 ticks/sという2つの出力条件から必然的に決まる値です。
このように1つの整数値を厳密に要求できるのは、許可するフレームレートをタイムスケールで割り切れるものに限定したからです。もし目標の表示間隔が単一の整数継続時間で表せないなら、検証処理は正しい整数列のパターンを検証すべきで、存在できない1値を要求すべきではありません。
フレームレート、タイムベース、MP4タイムスケールは別の概念
- フレームレートは表示の間隔です。CFR 16なら62.5 msごとに1フレームを表示します。
- FFmpegのタイムベースは、整数タイムスタンプ1単位の長さです。例えば
1/90000秒です。 - MP4トラックのタイムスケールは1秒あたりの単位数です。90,000なら1単位は
1/90000秒です。 - PTSは画像をいつ表示するかを表します。
- DTSは圧縮パケットをいつデコードする必要があるかを表します。
- パケットの継続時間はストリームのタイムベースでサンプルの長さを表します。
BフレームがあるH.264ではPTSとDTSが異なるのが正常な場合があります。したがってタイムスタンプを直すつもりで単純にPTS = DTSとするのは危険です。公式のsettsドキュメントでも、Bフレームがある場合にこの方法は推奨されていません。
私の検証処理は両者の一致ではなく、デコード順と表示順の関係が正当かを検証します。
90,000 tick/sを選んだ理由
90,000が普遍的な正解というわけではありません。私の用途では、許可したすべてのフレームレートが整数の継続時間になる点が便利でした。
| フレームレート | 90,000 tick/sでの1フレームの継続時間 |
|---|---|
| 10 fps | 9000 tick |
| 12 fps | 7500 tick |
| 15 fps | 6000 tick |
| 16 fps | 5625 tick |
| 18 fps | 5000 tick |
| 20 fps | 4500 tick |
| 24 fps | 3750 tick |
| 25 fps | 3600 tick |
| 30 fps | 3000 tick |
ソースのミリ秒単位のタイミングとも相性が良く、
1 ms = 90 ticks
となります。FFmpegのMP4マルチプレクサにはvideo_track_timescaleがあるため、このトラックグリッドを明示できます。ただしタイムスケールを指定しただけでタイミングが正しくなるわけではありません。正しいかどうかを整数で検証しやすくなる、というのが利点です。
5580という値が調査箇所を教えてくれた
最初の障害を時間に戻すと意味が見えてきます。
5580 / 90000 = 0.062 s = 62 ms
この62 msには見覚えがありました。実際に扱っていたアニメーションソースの1つは、3.063秒に49個の表示フレームがあり、フレーム遅延が62 msと63 msで交互になっていました。
49 / 3.063 ≈ 15.997 frames/s
62 ms + 63 ms = 125 ms
2 frames at 16 fps = 2 × 62.5 ms = 125 ms
ソース側では62/63 msの交互値は不自然ではありません。1 ms単位しかない時間軸で16 fpsを近似すると、非常に自然な表現です。
しかしCFR 16を選んだ後の出力は別です。通常のフレームは62.5 ms、つまり5625 tickの目標グリッドに乗る必要があります。
そのため5580は、ソースの62 msという継続時間が、すでにCFRに量子化されているはずの段階まで残っていた可能性を強く示しました。
ここでは証拠と推測を分けています。保存されたログから確実に言えるのは、90 kHzで5580 tickが正確に62 msであること、そして実際のソースに62/63 msの遅延があったことです。どの関数で最初にその値が残ったのかまでは、エラーの1行だけでは証明できません。
1000 Hzの入力クロックは原因ではなかった
この部分はFFmpeg 7.1.5とffconcatで別に再現しました。次の入力では、
duration 0.010
option framerate 1000
パケットのタイムスタンプは、意図したミリ秒位置を保持しました。
0 ms
10 ms
20 ms
30 ms
同じ早い段階で入力レートを30にすると、同じタイミングがおおむね0と33.3 msに量子化されました。
つまり1000 Hzは正しく働いていました。ソースの基準となる遅延を1 ms精度で保持するための入力タイムスタンプ用グリッドであって、最終動画を1000 fpsにする指定ではありません。
millisecond-accurate source delays
↓
authoritative source timeline
↓
choose target CFR
↓
explicitly quantize onto CFR grid
↓
preserve that grid through encoding and muxing
入力側の精度が高いこと自体は問題ではありません。問題は、ソースのタイミングから目標CFRへ移る境界を明示しないことです。
CFRへの変換は時間の量子化
62/63 msのソースは2フレーム単位で見ると16 fpsに自然に対応し、どちらも125 msを占めます。しかし最終ファイルに62、63、62、63 msという継続時間を残したまま「16 fps」と呼んでも、それは依然として可変継続時間です。
62 ms, 63 ms, 62 ms, 63 ms
↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms
FFmpegのfpsフィルタは、この変換を行う妥当な場所です。入力タイムスタンプと選択した丸め方針に基づき、フレームを破棄または複製して目標フレームレートを作ります。
一度そこで目標グリッドを作ったなら、後段で別のフレームレート変換を独立に行わせたくありません。量子化の判断点は1か所にし、複数の段階でそれぞれ別の丸め判断をさせない方が追跡しやすくなります。
3回再試行しても変わらなかった理由
FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625
これは動画に限らない信頼性の話です。再試行が有効なのは、次の試行では消える可能性がある障害です。ネットワーク障害、一時的なストレージ障害、リソース逼迫、依存先の一時停止などです。
同じ入力と同じアルゴリズムが毎回同じ契約違反を生むなら、再試行では直りません。
- 一時的な障害 — 再試行が有効な可能性がある。
- 不正な入力 — 別処理へ回すか拒否する。
- 決定論的な不変条件違反 — 再試行を止め、パイプラインを調べる。
繰り返された5580は3番目でした。
次は3750ではなく3751だった
24 fpsでは期待値は割り切れます。
90000 / 24 = 3750 ticks
ところが実際のconcat後の出力の1つに、
3751 ticks
のパケットがありました。差は、
1 / 90000 s ≈ 11.111 µs
です。視聴者が1 tickを知覚することはまずありません。そのため検証処理を±1だけ緩める案は魅力的でした。
しかし私は緩めませんでした。このグリッドでは24 fpsを3750という整数で正確に表せます。3751は表現上避けられない誤差ではなく、どこかで正確なグリッドという不変条件が失われた証拠だからです。
ストリームコピーでもタイムスタンプ処理は残る
互換性のあるH.264セグメントは、2回目の非可逆エンコードを避けるため次のようにconcatしていました。
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
final.mp4
-c:v copyが意味するのは、圧縮済みH.264データをデコードして再エンコードしないことです。多重化の段階がタイムスタンプを一切処理しない、という意味ではありません。
concatデマルチプレクサのドキュメントには、各ファイルの継続時間を使って次のファイルのタイムスタンプを調整すると明記されています。FFmpegは異なる有理数タイムベース間で整数タイムスタンプを再スケールし、libavutilには丸め方式を明示して再スケールする関数もあります。
だからといって、私が見た3751の原因がconcatだけだったと証明できるわけではありません。しかし「ビットストリームをコピーしたのだから、パケットのタイムスタンプも完全に不変」という理解は誤りです。
圧縮画像が同一であることと、タイムスタンプグリッドが同一であることは別の性質です。
PTSとDTSを単純な式で同じにしてはいけない
例えば次のように直したくなります。
PTS = N * frame_duration
DTS = N * frame_duration
しかしBフレームを使うH.264では不正になる可能性があります。画像をデコードする順番と表示する順番は一致するとは限りません。
必要なのは、基準となるセグメントのタイムラインから既知の表示グリッドとパケット継続時間を復元しつつ、正当なデコード順序の関係を壊さないことです。
そのため私は、文脈を無視して使える「魔法のsetts式」は掲載していません。正しい式はセグメント境界、選択したレート、最終タイムラインを構築したタイミング情報に依存します。
settsを使った理由
FFmpegのsettsビットストリームフィルタは、動画をデコードして再エンコードすることなく、パケットのPTS、DTS、継続時間、出力タイムベースを変更できます。
そのためconcat後の正規化を行う層として適していました。H.264の圧縮データは維持し、既知のセグメントメタデータから導いたパケットのタイムラインを再度適用できます。
known segment timeline
+ known CFR
+ 90000-tick grid
↓
known valid packet positions and durations
↓
normalize packet timing
↓
validate again
これは「3751なら1を引く」という対症療法ではありません。修正規則は、その日のエラーメッセージではなくタイミングモデルから導くべきです。
±1 tickを許容しなかった理由
小さな許容差が正しいシステムは多くあります。選んだ整数タイムベースで表示間隔を正確に表せないなら、検証処理は必要な丸めパターンをモデル化する必要があります。
私の契約は違いました。許可レートを90000 / fpsが整数になるものに限定しています。
16 fps → 5625
24 fps → 3750
30 fps → 3000
期待する継続時間を整数で正確に表せるのに一般的な±1を許すと、原因不明の不変条件違反を正常な状態として受け入れることになります。
1 tickそのものは視覚的に重要ではありません。設計した契約が理由不明のまま失われることが問題です。
- グリッドが必然的に複数の整数継続時間を交互に使うなら、その正しいパターンを検証する。
- 継続時間が1つの正確な整数であるべきなら、その値を厳密に要求する。
- 検証処理を成功にするための万能策として±1を使わない。
パケット単位でCFRを検証する
avg_frame_rateなどのストリームメタデータは参考になりますが、この契約には十分ではありません。
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
実際のストリームタイムベースと、各パケットのpts、dts、durationを調べます。
expected = 90000 / 16 // 5625
for each normal video packet:
assert packet.duration == 5625
assert decode ordering is legal
assert PTS/DTS relationship is legal for the codec
assert presentation timeline matches planned frame count and duration
ここで「通常パケット」という条件は重要です。編集リスト、トリミング、意図した特殊な終端サンプルなどがあるコンテナでは、その例外を明示的にモデル化する必要があります。これはすべてのMP4に適用する一般法則ではありません。
ただし構造を自分で管理する生成処理なら、「だいたい16 fps」に留めず厳密に検証する価値があります。
メタデータ検証と完全デコードは別の問題を調べる
タイムスタンプが完璧でも、H.264ストリーム全体を最後までデコードできる保証にはなりません。逆に全体を問題なくデコードできても、私のタイミング契約を満たすとは限りません。
ffprobe / packet validation
→ structure, timestamps, durations, stream parameters
full decode
→ whether the complete compressed stream can actually be decoded
パケット検査の後には、次も実行します。
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
両方を通ったファイルだけを公開対象にします。エンコーダの終了コードが0というだけでは、もう「完了」とは判定しません。
2つの障害から確実に言えること
5580について確認できるのは、再試行しても同じ値が再現したこと、90 kHzでは5580が正確に62 msであること、実ソースに62/63 msの遅延があったこと、CFR 16には62.5 msまたは5625 tickが必要なこと、そして1000 Hz入力の実験がミリ秒遅延を正しく保持したことです。
したがって、ソースの継続時間が、すでにCFR化されているべき段階まで残ったという説明には強い根拠があります。ただし、保存されたエラーの1行だけで原因となった正確な関数までは特定できません。
3751について確認できるのは、実際の出力に24 fpsで必要な3750ではなく3751 tickのパケットが存在したこと、パイプラインがストリームコピーによるconcatを使っていたこと、concat後のパケット時刻の正規化にsettsを使うようになったこと、そしてFFmpegがconcatによるタイムスタンプ調整とパケット単位のタイムスタンプ書き換えを公式に説明していることです。
これらの事実は、整数の再スケーリングや多重化/concat境界での丸めと整合しますが、「concatは必ず1 tick足す」という一般則を証明するものではありません。
現在の処理手順
- 単一の推測FPSに頼らず、基準となるソースのタイムラインを復元する。
- 元のミリ秒遅延を、十分に細かい入力クロックで保持する。
- 目標CFRは別の判断として選ぶ。
- ソースのタイムラインを明示的にCFRグリッドへ量子化する。
- 可能なら、許可レートを整数で正確に表せるトラックタイムスケールを使う。
- エンコード後の別段階に、独立したフレームレート変換を再実行させない。
- concat前にタイムベース、フレーム数、PTS/DTS、パケットの継続時間を検証する。
- ストリームコピーによるconcat前に、コーデック設定とタイミングの互換性を検証する。
- concat後もパケットグリッドを検証し、
-c:v copyをタイムスタンプ不変の証明とは考えない。 - 正規化が必要なら既知のタイムラインから導き、パケットレベルで適用する。
- 全パケットを再検証する。
- 最終ファイルを最後までデコードする。
- すべての契約を通った後にだけ、アトミックに公開する。
残った原則:CFRは整数のタイミング契約
以前は16 fpsという数字だけで十分説明できた気になっていました。今は違います。
time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering
5580はソース側の古いミリ秒グリッドを露出させたから重要でした。3751は逆に、ほぼ見えないほど小さな差だったから重要でした。たった1 tickでも、守ると決めた不変条件が失われたことは検出できます。
今のルールは単純です。「CFR」というラベルを検証するのではなく、そのラベルが成立するはずのタイミングそのものを検証する。
一次資料
- FFmpeg: fps フィルタ — 目標フレームレートの構築とPTSの丸め方針。
- FFmpeg: MOV/MP4 フォーマット —
video_track_timescaleとMP4マルチプレクサの設定。 - FFmpeg: concat デマルチプレクサ — ファイルの継続時間と連結時のタイムスタンプ調整。
- FFmpeg: setts ビットストリームフィルタ — パケットのPTS、DTS、継続時間、タイムベースの書き換え。
- ffprobe ドキュメント — ストリームとパケットの検査。
- FFmpeg libavutil: 時刻計算とタイムスタンプの再スケーリング — 有理数タイムベース間のタイムスタンプ再スケーリングと明示的な丸め方式。