ffprobeには位置情報が出るのに、座標をgrepしても0件だった。紹介動画のメタデータ消しをffmpegで2回やり直した

ffprobeには位置情報が出るのに、座標をgrepしても0件だった。紹介動画のメタデータ消しをffmpegで2回やり直した

ffprobeには、位置情報の行がはっきり出ています。

TAG:location=+35.6586+139.7454/

同じファイルで、その座標をそのままgrepにかけると0件でした。

grep -c -a "35.6586" original.mp4
0

見えるのに、文字列としては入っていません。これが今回の入口です。

種明かしをすると、この動画はffmpegで作った5秒の試験用動画です。映像はテストパターン、音は440Hzの正弦波で、位置情報は東京タワー付近の座標を手で入れました。誰かの自宅ではありません。AIに作らせた動画でもありません。

それでも作ったのは、デジタル商品の紹介動画を想定したからです。スマホで撮った動画は、撮影した場所や端末の情報を記録する場合もあります。商品ページへそのまま貼ると、その情報も一緒に配ります。同じ調べものは、これまでに写真とMP3でやりました。写真は11月7日の記事(商品写真の位置情報を消したら横倒しになった)、MP3は10月8日の記事(MP3のジャケット画像に仕込んだプロンプト)です。動画は3つ目のファイル形式です。

実機のスマホ動画では試していません。この記事の結果は、ffmpeg7.1.3で作った動画に限った話です。

まず動画を1本作る

最初に、メタデータを仕込んだ動画を作ります。

mkdir mp4lab && cd mp4lab
ffmpeg -hide_banner -loglevel error -y \
  -f lavfi -i testsrc2=size=1280x720:rate=30:duration=5 \
  -f lavfi -i sine=frequency=440:duration=5 \
  -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest \
  -metadata title="demo-sample" -metadata artist="sample-author" \
  -metadata comment="synthetic test pattern" \
  -metadata creation_time="2026-11-10T09:15:00Z" \
  -metadata location="+35.6586+139.7454/" \
  -metadata location-eng="+35.6586+139.7454/" \
  original.mp4

コマンドは何も表示せずに終わります。次はffprobeで、仕込んだ値の読み出しです。

ffprobe -v error -show_entries format_tags=title,artist,comment,location,creation_time -of default=nw=0 original.mp4
[FORMAT]
TAG:creation_time=2026-11-10T09:15:00.000000Z
TAG:title=demo-sample
TAG:artist=sample-author
TAG:comment=synthetic test pattern
TAG:location=+35.6586+139.7454/
[/FORMAT]

5件とも読めます。冒頭のgrepが0を返したのは、まさにこの状態のファイルでした。

座標は文字ではなく、数値で入っていた

最初は、grepが0件なら位置情報は入っていない、と読める結果でした。ffprobeの表示と食い違います。なぜか。ファイルの中身を直接見ることにしました。

MP4は、boxと呼ばれる塊を並べた形式です。映像と音の本体はmdat、再生の索引はmoovに入ります。作成者や題名のような付随情報は、moovの中のudtaにまとまります。

python3 - <<'EOF'
d = open("original.mp4", "rb").read()
print("loci" in d.decode("latin1"), d.find(b"loci"))
EOF
True 1933373

lociという名前のboxが、ファイルの後ろの方にありました。ffmpegはlocationタグをここへ書きます。中身は文字列の+35.6586ではなく、バイナリの数値。だから文字列検索には引っかかりません。

grepの0件だけを見ていたら、「入っていない」と判断するところでした。文字ではない形で入る値には、使えない確かめ方です。以降はffprobeの表示と、box名の探索を組み合わせます。

1回目: -map_metadata -1 で消す

ffmpegの公式ドキュメントでは、-map_metadataに負のファイル番号を渡すと、入力のメタデータを自動でコピーする動作を止められます。映像と音は再エンコードせず、-c copyで中身をそのまま運びます。

ffmpeg -hide_banner -loglevel error -y -i original.mp4 -map_metadata -1 -c copy first.mp4
ffprobe -v error -show_entries format_tags=encoder -of default=nw=0 first.mp4
grep -c -a "Lavf" first.mp4
[FORMAT]
TAG:encoder=Lavf61.7.100
[/FORMAT]
1

title、artist、comment、locationは消えました。ところが、新しく出た行があります。encoder=Lavf61.7.100。元の動画から引き継いだのではなく、ffmpegが書き出しのときに自分で足した行です。grepでも1件ありました。

残ったのは個人の情報ではなく、ツールの名前です。ただ、ffmpegを更新すると版数入りの行が変わります。ファイルを比べて「何も変わっていない」と確かめるとき、この行が邪魔になります。そこで、もう一度。

2回目: -fflags +bitexact を足す

ドキュメントでは、bitexactは「プラットフォーム、ビルド、時刻に依存するデータを書かない」ためのフラグです。出力側のオプションとして足します。入力側にも付けた版と出力側だけの版を作って比べましたが、結果は同じでした。

ffmpeg -hide_banner -loglevel error -y -i original.mp4 -map_metadata -1 -c copy -fflags +bitexact second.mp4
ffprobe -v error -show_entries format_tags=encoder -of default=nw=0 second.mp4
grep -c -a "Lavf" second.mp4
[FORMAT]
[/FORMAT]
0

encoderの行が消え、Lavfも0件になりました。

検査スクリプトに通すと、まだ落ちた

目で見て済ませず、検査を書きました。決めたのは3点です。

  • フォーマットとストリームのタグが、許可した名前だけであること
  • lociか©xyzのboxが残っていないこと
  • moovがmdatより前にあること

許可するのは、major_brand、minor_version、compatible_brandsと、ストリームのlanguage、handler_name、vendor_idです。この見本では、MP4として成立するための最低限のタグとして、この6つにしました。

消したいタグ名の一覧で弾く案も考えました。ただ、original.mp4にはlocationのほかに、location-engという別名の行も出ていました。名前の一覧は、載っていない別名をそのまま通します。そこで、通してよい名前だけを並べる方式にしました。

check_mp4.py の全文
import json
import struct
import subprocess
import sys

ALLOWED_FORMAT = {"major_brand", "minor_version", "compatible_brands"}
ALLOWED_STREAM = {"language", "handler_name", "vendor_id"}


def top_boxes(data):
    pos, names = 0, []
    while pos + 8 <= len(data):
        size, kind = struct.unpack(">I4s", data[pos:pos + 8])
        if size == 1:
            size = struct.unpack(">Q", data[pos + 8:pos + 16])[0]
        if size == 0:
            size = len(data) - pos
        if size < 8:
            break
        names.append(kind.decode("latin1"))
        pos += size
    return names


def check(path):
    probe = subprocess.run(
        ["ffprobe", "-v", "error", "-print_format", "json",
         "-show_format", "-show_streams", path],
        capture_output=True, text=True)
    if probe.returncode != 0:
        print(f"{path}: ffprobe が読めません")
        return 2
    info = json.loads(probe.stdout)
    if not any(s["codec_type"] == "video" for s in info.get("streams", [])):
        print(f"{path}: 映像ストリームがありません")
        return 2

    problems = []
    for key in info["format"].get("tags", {}):
        if key not in ALLOWED_FORMAT:
            problems.append(f"format タグ {key}")
    for s in info["streams"]:
        for key in s.get("tags", {}):
            if key not in ALLOWED_STREAM:
                problems.append(f"stream タグ {key}")

    data = open(path, "rb").read()
    if b"loci" in data or b"\xa9xyz" in data:
        problems.append("位置情報の box が残っている")
    boxes = top_boxes(data)
    if "moov" not in boxes or "mdat" not in boxes:
        print(f"{path}: moov か mdat がありません")
        return 2
    if boxes.index("moov") > boxes.index("mdat"):
        problems.append("moov が mdat より後ろ(faststart ではない)")

    if problems:
        for p in problems:
            print(f"{path}: NG {p}")
        return 1
    print(f"{path}: OK")
    return 0


worst = 0
for p in sys.argv[1:]:
    worst = max(worst, check(p))
sys.exit(worst)

終了コードは、問題があれば1、ファイルを読めなければ2。2回目の動画を通します。

python3 check_mp4.py second.mp4; echo "exit=$?"
second.mp4: NG moov が mdat より後ろ(faststart ではない)
exit=1

タグは全部通りました。落ちたのはmoovの位置です。ffmpegはデフォルトで、索引のmoovをファイルの末尾に置きます。公式ドキュメントでは、これを先頭へ移すと再生が良くなると書かれています。ページに埋め込んだ動画が、全部ダウンロードされる前に再生を始められる、という理屈です。

再生の速さは測っていません。確かめたのはドキュメントの記述だけ。載せ先で再生が遅いと困るので、検査には入れました。

3回目: -movflags +faststart で索引を前へ

ffmpeg -hide_banner -loglevel error -y -i original.mp4 -map_metadata -1 -c copy -fflags +bitexact -movflags +faststart third.mp4
python3 check_mp4.py third.mp4; echo "exit=$?"
third.mp4: OK
exit=0

4本の結果を並べます。

ファイル作り方検査の結果
original.mp4作ったまま1(12行のNG)
first.mp4-map_metadata -11(encoderと、moovが後ろ)
second.mp4+ -fflags +bitexact1(moovが後ろ)
third.mp4+ -movflags +faststart0

表で見ると、中身の掃除は1回目でほぼ済んでいました。残りの2回は、配るときの形を整える作業です。

デコードも通りました。ffmpeg -i third.mp4 -f null -の終了コードは0。幅1280・高さ720のh264とaacが、5秒分そのまま入っています。作成時刻の扱いも変わりました。元のmvhdに入っていた3877146900という数値が、third.mp4では0です。

壊れた入力で、0を返していないか

読めないファイルを問題なしで通す穴は、検査スクリプトでありがちです。悪い入力も流しました。空のファイル、動画ではないテキスト、音声だけのMP4の3つです。音声だけのファイルは、画面録画が音だけになって書き出された失敗を想定しました。映像が無いのに「問題なし」で通ると、そのまま商品ページに載せてしまいます。

python3 check_mp4.py empty.mp4 fake.mp4 audio_only.mp4; echo "exit=$?"
empty.mp4: ffprobe が読めません
fake.mp4: ffprobe が読めません
audio_only.mp4: 映像ストリームがありません
exit=2

3つとも、0ではなく2で止まりました。「問題あり」の1と「検査できなかった」の2を分けたのは、読めないファイルを問題なしの扱いにしたくなかったからです。

まだ納得していないこと

third.mp4には、x264 - core 164 ...で始まる文字列がまだ1件残っています。H.264の映像の中に入る、エンコーダの設定の記録です。個人の情報ではありませんが、どのツールで作ったかは分かります。消すには再エンコードかビットストリームの加工が要りそうで、今回は試していません。検査スクリプトも、この文字列は見ていません。

AI動画ツールの書き出しで同じ位置情報が入るかも、確かめていません。AIで動画を作る回なら、そのツールの出力を1本ずつ通す必要があります。

もう1つ、実機のファイル。スマホ動画には、この見本の動画にはない種類のboxがあるかもしれません。許可リスト方式にしたので、未知のタグはNGで止まります。ただ、止まりすぎて正しい動画まで弾く可能性もあります。試すなら、手元の本物の動画で1本ずつ確かめる作業。

この見本は、合成した紹介動画を載せる前の確認までを対象にしています。実機のスマホ動画は通していません。許可リストを広げるかどうかは、本物の動画を通した結果で決まる話です。

出典

この記事をシェア