ffmpegのエンコード指定を出力ファイル名より後ろに置くと効かない──96000Hzのまま連結して尺が43秒に化けた話
ffmpegはオプションを「次に指定されたファイル」に適用するため、出力ファイル名の後ろに置いた -c:a aac -ar 44100 は反映されません。終了コードは0、警告は出るもののwarningレベルなので -v error では消えます。実測では44100Hzのピースと96000Hzのピースをconcatした瞬間、20.02秒であるべき出力が43.560635秒になりました。あわせてffprobeの -of csv=p=0 がside dataを持つストリームで末尾に空カラムを足す(h264ではなくh264,)挙動も検証します。
目次
ffmpegのコマンドで -c:a aac -ar 44100 のようなエンコード指定を出力ファイル名より後ろに置くと、その指定は出力に反映されません。しかも終了コードは0で返ってきます。公式ドキュメントが「オプションは次に指定されたファイルに適用される」と明記している仕様どおりの挙動で、バグではありません。結果として音声が元のサンプルレート(筆者の場合は96000Hz)のまま残り、44100Hzの別ピースとconcatで連結した瞬間に尺が壊れます。直し方は単純で、すべてのオプションを出力ファイル名より前に書くだけです。この記事では、その挙動をffmpeg 8.0.1で実際に再現して測った数値と、あわせて踏んだ ffprobe -of csv=p=0 の落とし穴を記録します。
- ffmpegのオプションは「次に指定されたファイル」に適用される仕様。出力ファイル名の後ろに書いたエンコード指定は出力に効かず、終了コードは0のまま
- 警告
Trailing option(s) found in the command: may be ignored.は出るがwarningレベルなので、-v errorで走らせているビルドスクリプトでは完全に消える- 44100Hzのピースの後ろに96000Hzのピースをconcatした実測結果は、20.023220秒であるべき出力が43.560635秒。2本目の開始位置が21.768707秒(= 10 × 96000/44100)にずれた
症状1: 出力ファイル名の後ろのオプションが効かない
まず、96000Hzの音声を持つ10秒のmp4を用意します。
ffmpeg -f lavfi -i "testsrc=size=320x240:rate=30:duration=10" \
-f lavfi -i "sine=frequency=440:sample_rate=96000:duration=10" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -ar 96000 -shortest src96k.mp4
これを44100Hzに変換するつもりで、エンコード指定を出力ファイル名の後ろに書いた場合と前に書いた場合を比べます。
# ダメな順序(出力ファイル名 bad.mp4 の後ろにオプションがある)
ffmpeg -i src96k.mp4 -c:v copy bad.mp4 -c:a aac -ar 44100
# 正しい順序
ffmpeg -i src96k.mp4 -c:v copy -c:a aac -ar 44100 good.mp4
結果(ffprobeで実測):
| コマンド | 終了コード | 出力の sample_rate |
|---|---|---|
... -c:v copy bad.mp4 -c:a aac -ar 44100 |
0 | 96000 |
... -c:v copy -c:a aac -ar 44100 good.mp4 |
0 | 44100 |
厄介なのは、失敗が終了コードに出ないことです。シェルスクリプトで set -e を使っていても素通りします。
なお、デフォルトのログレベルではffmpeg 8.0.1は次の1行を出していました。
Trailing option(s) found in the command: may be ignored.
ただしこれはwarningレベルです。筆者が実測した限り、-v error を付けて実行するとstderrは0バイトになり、この警告は一切表示されませんでした(終了コードは0、出力のsample_rateは96000のまま)。ビルドスクリプトでログを静かにするために -v error や -loglevel error を付けるのはよくある書き方なので、そこで警告が消えるのがこの事故の入口です。
症状2: サンプルレートの違うピースをconcatすると尺が壊れる
44100Hzのピース(10秒)と96000Hzのピース(10秒)を用意し、concat demuxerで無劣化連結します。
printf "file 'p44.mp4'\nfile 'p96.mp4'\n" > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
このコマンドも終了コード0で正常終了します。エラーは出ません。しかし出力を測ると壊れています。実測値は次のとおりです。
| 連結の並び | format=duration | 音声ストリームの duration | 映像ストリームの duration |
|---|---|---|---|
| 44100 + 44100(対照) | 20.023220 | 20.023220 | 20.000000 |
| 44100 → 96000 | 43.560635 | 43.560635 | 20.000000 |
| 96000 → 44100 | 20.010026 | 10.016917 | 20.000000 |
真の内容は10秒+10秒=20秒です。44100Hzを先頭にして96000Hzを後ろに繋いだ場合、映像ストリームは20.000000秒のままなのに音声ストリームだけが43.560635秒に膨らみ、コンテナ全体の尺もそれに引きずられました。
順序を逆にした場合(96000Hzが先頭)は症状が変わり、コンテナの尺は20.010026秒と正しく見えるのに、音声ストリームが10.016917秒に切り詰められていました。どちらの並びでもエラーは出ません。
パケットを覗くと何が起きているか
壊れた出力(44100 → 96000)の音声パケットのタイムスタンプを取ると、原因がそのまま見えます。
ffprobe -v error -select_streams a -show_entries packet=pts_time,duration_time -of csv=p=0 out.mp4
- 全1371パケットのうち1369個が
duration_time=0.023220を持っていました。これは 1024/44100 秒、つまりAACの1フレーム(1024サンプル)を44100Hzとして数えた長さです。本来96000Hz側のパケットは 1024/96000 = 0.010667 秒であるべきですが、そちらも44100Hz基準の長さになっていました。 - 2本目の中身の開始位置は、本来なら1本目の終端である10.007800秒付近のはずが、21.768707秒にずれていました。この値は 10 × (96000/44100) = 21.768707482… と小数第6位まで一致します。
- 境界には
duration_time=11.760907という長さの1パケットが挟まっていました。21.768707 − 10.007800 = 11.760907 で、ちょうど上のずれ幅ぶんの穴が空いた形です。
つまり、96000Hzで刻まれたサンプル数がそのまま44100Hzのタイムベースの目盛りとして読み替えられ、96000/44100 = 2.176871 倍に引き伸ばされていました。
さらに、この壊れた出力の音声をwavにデコードしようとすると、大量のデコードエラーが出ます。実際に出た文字列のうち、原因を名指ししているものがこれです。
Sample rate index in program config element does not match the sample rate index configured by the container.
同時に次のような行も並びました。
Input buffer exhausted before END element found
Number of bands (30) exceeds limit (18).
channel element 0.1 is not allocated
Prediction is not allowed in AAC-LC.
ただし、これらの行の括弧内の数字(帯域数とその上限、チャンネル要素の番号)や出現件数は、壊れたビットストリームをどう読み違えるかで決まるため素材ごとに変わります。同じ手順で素材を作り直して検証したときは Number of bands (28) exceeds limit (11). のように別の数字になりました。上のブロックは筆者の手元で出た1例です。
デコードして書き出せたwavは13秒台にしかなりませんでした(筆者の計測値は13.003175秒。ただしこの尺も素材依存で、検証のために素材を作り直すと13.0〜13.3秒の範囲でばらつき、13.003175秒という値そのものは再現できていません)。コンテナの申告は43.560635秒、真の内容は20秒、デコードできたのは13秒台――三者がすべて食い違う状態です。
原因: 公式ドキュメントに明記された仕様
ffmpeg公式ドキュメントには、次のとおり書かれています(2026-08-14に取得)。
As a general rule, options are applied to the next specified file. Therefore, order is important, and you can have the same option on the command line multiple times. Each occurrence is then applied to the next input or output file. Exceptions from this rule are the global options (e.g. verbosity level), which should be specified first.
(訳:一般的な規則として、オプションは次に指定されたファイルに適用されます。したがって順序が重要であり、同じオプションをコマンドラインに複数回置くこともできます。その場合、各出現は次の入力ファイルまたは出力ファイルに適用されます。この規則の例外はグローバルオプション(冗長度レベルなど)で、これらは最初に指定すべきです。)
続けて、混ぜるなという注意も明記されています。
Do not mix input and output files – first specify all input files, then all output files. Also do not mix options which belong to different files. All options apply ONLY to the next input or output file and are reset between files.
(訳:入力ファイルと出力ファイルを混在させないでください――まずすべての入力ファイルを指定し、次にすべての出力ファイルを指定します。また、異なるファイルに属するオプションを混在させないでください。すべてのオプションは次の入力ファイルまたは出力ファイルだけに適用され、ファイルごとにリセットされます。)
「オプションは直後のファイルにかかる」――この一文がすべてです。出力ファイル名の後ろに置かれた -c:a aac -ar 44100 は、その次に来るはずの(存在しない)ファイルに向けられており、すでに書き終えた出力には届きません。
対処
1. オプションは必ず出力ファイル名より前に書く
# 入力 → 入力用オプション → 出力用オプション → 出力ファイル名
ffmpeg -i in.mp4 -c:v copy -c:a aac -ar 44100 out.mp4
シェル関数でffmpegを包むときは、"$@" の展開位置に注意してください。出力ファイル名が "$@" に含まれていると、後ろに書いたエンコード指定が出力名より後ろに落ちます。
# 壊れる形: "$@" に出力ファイル名が入っているため、
# エンコード指定が出力名の後ろに展開される
enc() {
ffmpeg -i "$1" "$@" -c:a aac -ar 44100
}
# 安全な形: 入力と出力を別の変数で受け、出力名は必ず最後に置く
enc() {
local in="$1" out="$2"
ffmpeg -i "$in" -c:v copy -c:a aac -ar 44100 "$out"
}
2. concatの前に全ピースのサンプルレートを機械で揃っているか確認する
concatは黙って通るので、通ったこと自体は検品になりません。連結前に各ピースを測り、値が1種類であることを確認します。
for f in piece*.mp4; do
ffprobe -v error -select_streams a:0 \
-show_entries stream=sample_rate -of default=nw=1:nk=1 "$f"
done | sort -u
2行以上出たら、その時点で止めます。
3. 連結後は「合計と一致するか」を測る
各ピースの尺の合計と、連結後の尺を突き合わせます。今回のケースでは、期待20.02秒に対して実測43.56秒だったので、この1行のチェックだけで検出できました。
併発した罠: ffprobe の -of csv=p=0 が末尾に空カラムを足す
上のチェックを書くとき、-of csv=p=0 の出力を期待値と文字列一致で比較するゲートを作ったのですが、これが全ピースで誤検知しました。原因は、side dataを持つストリームでは末尾に空のカラムが1個増えることです。
実際に測った結果です(Display Matrix、つまり回転情報を持つファイルを -display_rotation 90 で作って比較)。
| 出力形式 | side dataなし | side dataあり |
|---|---|---|
-of csv=p=0(stream=codec_name) |
h264 |
h264, |
-of csv=p=0(stream=codec_name,width,height) |
h264,320,240 |
h264,320,240, |
-of compact=p=0:nk=1 |
h264 |
h264| |
-of default=nw=1:nk=1 |
h264 |
h264 |
同じファイルをJSONで出すと、増えているものの正体が見えます(同時に出力される programs と stream_groups の空配列は省略しています)。
{
"streams": [
{
"codec_name": "h264",
"side_data_list": [
{
}
]
}
]
}
codec_name しか要求していないので side_data_list の中身は空ですが、セクション自体は出力されます。csvライターは各セクションを1行に平坦化するため、この空セクションが末尾の空フィールドとして現れる、という筋道です。ffprobe公式ドキュメントは csv ライターについて次のように記述しています。
The csv writer is equivalent to compact, but supports different defaults.
(訳:csvライターは compact と等価ですが、既定値が異なります。)
上の表で compact=p=0:nk=1 が h264| と、区切り文字だけ違う同じ症状を出しているのは、この記述と整合します。一方 default=nw=1:nk=1 では両方とも h264 になりました。文字列一致で検品するなら -of default=nw=1:nk=1 を使うか、JSONで出してパースするのが安全です。
なお筆者が確認したのは、Display Matrix(回転情報)を持つストリームで空カラムが増えることだけです。回転以外のside data(HDRのマスタリング情報など)でも同じになるかは検証していません。ただし「side dataを持つかどうか」でゲートの合否が変わる以上、比較対象を codec_name に絞っていても安全にはならない、という点は共通します。
踏んだ記録
2026-08-13、案件動画の本編と挿入クリップを連結する工程で、この2つを同じ日に踏みました。macOS上のシェルスクリプトで組んだビルドパイプラインです。
1つ目は、前回の案件で通ったエンコードスクリプトが手元にあったのに、読みやすさのために enc() というヘルパー関数へ書き換えたことが発端でした。"$@" に出力ファイル名が含まれていたため、エンコード指定が出力名の後ろに落ち、音声が96000Hzのまま残りました。それに気づかないまま44100Hzのピースとconcatし、尺が壊れました。当時の記録には倍率を「2.177倍(= 96000/44100)」と残しています。
2つ目は、そのミスを二度と通さないために書いた検品ゲートのほうが誤検知した件です。-of csv=p=0 の出力を期待文字列と比較する実装で、末尾の空カラムを想定していなかったため、全ピースが不合格になりました。
同じ工程でこの日は合計5回失敗しています。共通していたのは、ffmpegとffprobeの実際の出力を一度も見ずに、仮定でコードを書いて本番の全ピースに適用したことでした。1ピースで1回試して出力を目で見ていれば、どちらも数十秒で分かる話です。教訓として残しているのは次の2点です。
- 出力フォーマットに依存する比較(文字列一致)を書くときは、期待値を頭で書かず、実出力をコピーして期待値にする
- 前回通った実証済みスクリプトがある工程は、整理や可読性のために書き換えない
正直な但し書き
- この記事の数値は、2026-08-14にmacOS 15.1.1(Apple Silicon)/Homebrew版 ffmpeg・ffprobe 8.0.1 で筆者が再現して測ったものです。 2026-08-13の実際の事故は同じマシンで起きましたが、当時のffmpegのバージョンを記録していないため、事故当時と再現環境が完全に同一だとは確認できていません。
- 事故当時の記録には「尺が2.177倍」と残していますが、今回の再現では総尺の比は 43.560635 / 20.023220 = 2.1755 でした。96000/44100 = 2.176871 と小数第6位まで一致したのは総尺の比ではなく、**2本目の開始位置のずれ(21.768707秒 = 10 × 96000/44100)**のほうです。総尺の比が理論値とわずかにずれるのは、境界に挟まる穴のパケットと末尾の端数パケットぶんだと考えられますが、そこまでは検算していません。
- 壊れた出力をデコードしたときのエラー行の数字・件数と、書き出せたwavの尺は、壊れたビットストリームの読み違え方に依存するため素材ごとに変わります。同じ手順で素材を作り直して検証したところ、コンテナ側の数値(43.560635秒、音声1371パケット、境界の11.760907秒、2本目の開始21.768707秒)は完全に一致しましたが、デコード側の値は一致しませんでした。デコード側の数値は「三者が大きく食い違う」という定性的な事実の例示として読んでください。
Trailing option(s) found in the command: may be ignored.という警告が、どのバージョンのffmpegから出るようになったかは調べていません。手元の8.0.1で出ることだけを確認しています。 古いバージョンでは本当に無警告の可能性がありますが、確認できていません。- 上の再現は、映像パラメータを揃えた合成素材(libx264 320x240 30fps、lavfiのsine波)で行いました。実素材(可変フレームレート、複数音声トラック、HDRメタデータ付きなど)では症状の出方が変わる可能性があります。
-of csv=p=0の末尾空カラムについて、ffprobe公式ドキュメントに「side dataがあると空フィールドが増える」と明記された記述は見つけられませんでした。上の説明はJSON出力で空のside_data_listセクションが出ていること、および compact ライターで同じ症状が出ることからの推定です。実測した挙動は事実ですが、その理由づけは筆者の推定である点を区別してください。- concat demuxerの挙動は、mp4以外のコンテナや、
-c copyを使わない再エンコード連結では異なります。この記事はmp4+-c copyのケースだけを扱っています。
関連記事
- Remotionで長い動画のレンダリングがクラッシュする──分割レンダとffmpeg無劣化連結で完走させる手順:分割レンダしたピースをconcatで繋ぐ工程そのものの解説です。この記事の事故は、その工程で起きました
- ffmpegでナレーションとBGMをミックスする手順──全体loudnormでBGMが浮く音量事故を防ぐ処理順序:同じくffmpegの「順序」で結果が変わる話ですが、こちらはフィルタの処理順序の話です
- YouTubeチャプターの概要欄を自動作成する──台本データと音声の実尺から計算する考え方:
ffprobe -of csv=p=0で実尺を取る側の記事です。音声ファイル(wav)にはside dataが付かないため、この記事の末尾空カラム問題は通常起きませんが、映像ファイルに同じ書き方を流用するときは注意してください
出典
出典・参照資料
AIニュースの解説を動画でも
YouTubeでは注目ニュースの背景を解説し、Xでは新着記事をお知らせしています。
コメント
まだコメントはありません。最初のコメントを書いてみませんか?
AIについて聞きたいことはありますか?
質問箱で無料で受け付けています。回答は公開され、他の方の参考にもなります。
質問箱を見る →新しい記事をメールで受け取る
AIの新しい発表を、出典付きで整理して届けます。