MP3のジャケット画像に仕込んだプロンプトは、ffprobeでは見えなかった

ffprobeで見ると、その MP3 のタグはtitle、artist、albumの3行だけでした。きれいなものです。
ところが同じファイルをstringsにかけると、余計な1行が出てきます。
prompt: calm lofi cover, seed 8841, model draft-3
ジャケット画像の中に、画像を作ったときの指示文が残っている。タグ一覧には出てこない場所です。
種明かしをすると、これは私が仕込んだものです。音はffmpegで鳴らした3秒の正弦波で、タグもジャケットも手で入れた見本です。実際の AI 音楽サービスが何を書き込むかは、まだ調べていません。今回確かめたのは、自分の書いた検査が仕込んだものを止められるか。それだけです。
そして、すぐには止められませんでした。最初の検査は不備を入れた6本を1本も止めず、全部止まったのは3回目です。途中では、余計なものを消すつもりで曲名まで消しました。
前回は Word の請求書で、画面に出ない変更履歴を探しました(10月7日の記事)。今回は同じことを MP3 で試します。見ていない形式(ID3v1、APEv2、WAV、AAC、JPEGのジャケット)は、最後の「今回やって失敗したこと」にまとめました。音声そのものの権利関係も、この検査の範囲外です。
なぜ ffprobe の3行では足りないのか
MP3 のタグは、ID3v2 という形式で、TIT2(曲名)やTPE1(アーティスト)のような「フレーム」を並べて持ちます。フレームの種類は決まっていて、コメントはCOMM、歌詞はUSLT、独自の項目はTXXX、ジャケット画像はAPICです。エンコーダ名を入れるTSSEもあります。
ffprobeはそのうち、名前を付けて表示できるものだけをTAG:の行にします。ジャケット画像は、タグではなく映像ストリームとして扱われます。画像の中身までは表示しません。
そして画像ファイル自体にも、PNG ならtEXt、zTXt、iTXtというテキスト用のチャンクがあります。画像生成ツールによっては、ここにプロンプトや seed 値を書き込むことがあります。販売する MP3 の中に、買い手が取り出せる形で残ります。
AI に曲やジャケットを作らせて、タグ編集ソフトで整える流れを考えます。この流れだと、途中の作業メモが何かのフレームに紛れ込みやすくなります。作った人のローカルパスやメールアドレスが残る例も考えられます。
[AIで曲とジャケットを作る] → [タグ編集ソフトで整える] → [ffprobeで確認]
│ 見えない層は分からない
▼
[検査: フレーム一覧 / APICの中のPNGメタデータ]
│
┌─────────────┴─────────────┐
PASS FAIL
│ │
▼ ▼
[販売候補] [許可外を外す → 音声一致を確認 → 再検査]
用語
| 用語 | この回での意味 |
|---|---|
| MP3 | 音声の圧縮形式です。この回では、音声データの前にタグが付いたファイルを指します |
| ID3v2 | MP3の先頭に付けるタグの形式です。複数のフレームを並べて持ちます |
| フレーム | ID3v2の1項目です。4文字のIDで種類が決まります。TIT2は曲名です |
COMM | コメントのフレームです。作業メモが入りやすい場所です |
TXXX | 利用者が名前を付けて作る、独自のテキストフレームです |
USLT | 歌詞や書き起こしのテキストを入れるフレームです |
APIC | 画像を埋め込むフレームです。ジャケットに使います |
TSSE | 保存に使ったソフトや設定を入れるフレームです。ffmpegはLavfの版を書き込みます |
| ffprobe | メディアの情報を表示するコマンドです。ffmpegに付いてきます |
| mutagen | PythonでID3タグを読み書きするライブラリです |
| Pillow | Pythonの画像ライブラリです。PNGのテキストチャンクをinfoで読めます |
| PNGのテキストチャンク | PNG内のtEXtなどの領域です。画像に文字列を持たせられます |
| 許可リスト | 通してよいものだけを列挙する方式です。載っていないものはすべて止めます |
| 偽陰性 | 不備があるのにPASSと判定することです |
| 偽陽性 | 問題がないのにFAILと判定することです |
8つの見本を用意する
作業用のフォルダを作り、道具の版を確かめました。
mkdir hibanas-mp3-lab
cd hibanas-mp3-lab
python3 --version
ffmpeg -version | head -1
python3 -c "import mutagen, PIL; print(mutagen.version_string, PIL.__version__)"
Python 3.13.5
ffmpeg version 7.1.3-0+deb13u1+rpt1 Copyright (c) 2000-2025 the FFmpeg developers
1.47.0 11.1.0
mutagen と Pillow はpip install mutagen pillowで入ります。
見本は 8 つです。内訳は次のとおりです。
| ファイル名 | 入れたもの | 判定の正解 |
|---|---|---|
a_clean | 曲名、アーティスト、アルバムだけ | 通す |
b_prompt_in_comment | コメントにプロンプト | 止める |
c_generator_frame | 独自フレームに生成ツール名と seed 値 | 止める |
d_local_path | 独自フレームにローカルパス | 止める |
e_cover_with_prompt | ジャケット画像の中にプロンプト | 止める |
f_email_in_artist_note | コメントにメールアドレス | 止める |
g_lyrics_draft | 歌詞フレームに没にした歌詞 | 止める |
h_title_has_prompt_word | 曲名に「Prompt」の単語があるだけ | 通す |
最後の h は、中身は問題のないものです。後で偽陽性を見るために入れました。
以下がmake_mp3.pyの全文です。
import subprocess
from pathlib import Path
from mutagen.id3 import (APIC, COMM, ID3, TALB, TCOP, TIT2, TPE1, TXXX, USLT,
ID3NoHeaderError)
from PIL import Image, PngImagePlugin
OUT = Path("samples")
OUT.mkdir(exist_ok=True)
BASE_TAGS = [TIT2(encoding=3, text="Morning Loop"), TPE1(encoding=3, text="hibanas"),
TALB(encoding=3, text="BGM Pack Vol.1")]
def render(name):
path = OUT / f"{name}.mp3"
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i",
"sine=frequency=440:duration=3", "-c:a", "libmp3lame", "-b:a", "128k",
"-map_metadata", "-1", str(path)], check=True)
return path
def tag(path, frames):
try:
t = ID3(path)
except ID3NoHeaderError:
t = ID3()
t.delete()
for f in BASE_TAGS + frames:
t.add(f)
t.save(path, v2_version=3)
def cover(with_text):
img = Image.new("RGB", (600, 600), (30, 60, 90))
meta = PngImagePlugin.PngInfo()
if with_text:
meta.add_text("parameters", "prompt: calm lofi cover, seed 8841, model draft-3")
p = OUT / "cover.png"
img.save(p, pnginfo=meta)
return p.read_bytes()
samples = {
"a_clean": [],
"b_prompt_in_comment": [COMM(encoding=3, lang="eng", desc="", text="prompt: lofi piano, 80bpm, rainy night, no vocals")],
"c_generator_frame": [TXXX(encoding=3, desc="generator", text="ExampleMusicGen v3 / seed 8841")],
"d_local_path": [TXXX(encoding=3, desc="source_file", text="C:\\Users\\tanaka\\Desktop\\bgm_final_v3_NG.wav")],
"e_cover_with_prompt": [APIC(encoding=3, mime="image/png", type=3, desc="cover", data=cover(True))],
"f_email_in_artist_note": [COMM(encoding=3, lang="eng", desc="", text="contact: [email protected]")],
"g_lyrics_draft": [USLT(encoding=3, lang="eng", desc="", text="draft lyrics: v1 は没, サビを差し替える")],
"h_title_has_prompt_word": [],
}
for name, frames in samples.items():
p = render(name)
tag(p, frames)
if name == "h_title_has_prompt_word":
t = ID3(p)
t.add(TIT2(encoding=3, text="Prompt Jingle"))
t.add(TCOP(encoding=3, text="2026 hibanas. Commercial use allowed."))
t.save(p, v2_version=3)
print(name, p.stat().st_size, "bytes")
python3 make_mp3.py
ls samples
a_clean 50093 bytes
b_prompt_in_comment 50213 bytes
c_generator_frame 50190 bytes
d_local_path 50220 bytes
e_cover_with_prompt 52540 bytes
f_email_in_artist_note 50179 bytes
g_lyrics_draft 50173 bytes
h_title_has_prompt_word 50093 bytes
a_clean.mp3
b_prompt_in_comment.mp3
c_generator_frame.mp3
cover.png
d_local_path.mp3
e_cover_with_prompt.mp3
f_email_in_artist_note.mp3
g_lyrics_draft.mp3
h_title_has_prompt_word.mp3
ffprobeで独自フレームのある見本を見ます。
ffprobe -v error -show_entries format_tags -of default=nw=1 samples/c_generator_frame.mp3
TAG:title=Morning Loop
TAG:artist=hibanas
TAG:album=BGM Pack Vol.1
TAG:generator=ExampleMusicGen v3 / seed 8841
独自フレームはffprobeに見えました。一方、ジャケットの中のプロンプトは、ffprobeのタグ表示には出ません。バイト列から文字列を拾うstringsで探すと見つかります。
strings -n 8 samples/e_cover_with_prompt.mp3 | grep prompt
prompt: calm lofi cover, seed 8841, model draft-3
1回目: 3項目の有無だけを見る検査
最初に書いたのは、title、artist、albumがそろっているかを見る検査です。商品としてタグが空では困るので、それが最低限の確認だと考えました。check_v1.pyです。
import json, subprocess, sys
from pathlib import Path
def tags(path):
out = subprocess.run(["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", str(path)], capture_output=True, text=True, check=True)
return json.loads(out.stdout)["format"].get("tags", {})
for p in sorted(Path("samples").glob("*.mp3")):
t = tags(p)
ok = all(t.get(k) for k in ("title", "artist", "album"))
print(f"{p.stem:26} {'PASS' if ok else 'FAIL'}")
python3 check_v1.py
a_clean PASS
b_prompt_in_comment PASS
c_generator_frame PASS
d_local_path PASS
e_cover_with_prompt PASS
f_email_in_artist_note PASS
g_lyrics_draft PASS
h_title_has_prompt_word PASS
8 つとも通りました。不備のある 6 件を 0 件しか止められていません。タグがそろっていることと、余計なものが入っていないことは別の問題でした。この検査は、前者しか見ていませんでした。
2回目: 怪しい文字列を探す検査
次は、ffprobeが出すタグの中身に、prompt、seed、generator、Windows のユーザーパス、/home/、メールアドレスらしい並びがあれば止める検査にしました。check_v2.pyです。
import json, re, subprocess
from pathlib import Path
PATTERN = re.compile(r"prompt|seed|generator|C:\\Users|/home/|@[a-z0-9.-]+\.[a-z]{2,}", re.I)
def tags(path):
out = subprocess.run(["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", str(path)], capture_output=True, text=True, check=True)
return json.loads(out.stdout)["format"].get("tags", {})
for p in sorted(Path("samples").glob("*.mp3")):
hits = [f"{k}={v}" for k, v in tags(p).items() if PATTERN.search(f"{k}={v}")]
print(f"{p.stem:26} {'FAIL' if hits else 'PASS'} {hits[0][:48] if hits else ''}")
python3 check_v2.py
a_clean PASS
b_prompt_in_comment FAIL comment=prompt: lofi piano, 80bpm, rainy night,
c_generator_frame FAIL generator=ExampleMusicGen v3 / seed 8841
d_local_path FAIL source_file=C:\Users\tanaka\Desktop\bgm_final_v3
e_cover_with_prompt PASS
f_email_in_artist_note FAIL comment=contact: [email protected]
g_lyrics_draft PASS
h_title_has_prompt_word FAIL title=Prompt Jingle
止まったのは 6 件中 4 件でした。ただ、問題が 2 つ出ています。
1 つ目は偽陰性です。e_cover_with_promptは、ジャケットの中にある文字列をffprobeのタグ表示が出さないので、通りました。g_lyrics_draftも、歌詞の文面に検索語が入っていなかったので通りました。歌詞のほうは、ffprobeの表示にlyrics-engとして出ていました。それでも、没にした歌詞だと判断できる語を私が登録していなかったので、素通りしました。
2 つ目は偽陽性です。曲名が「Prompt Jingle」のh_title_has_prompt_wordは、問題のないファイルなのに止まりました。語を探すだけでは、タグの中身が危ないのか、単に単語が入っているだけなのか区別できません。
3回目: フレームを許可リストで読む
発想を変えました。危ない語を探すのをやめて、販売してよいフレームを先に決めます。曲名、アーティスト、アルバム、著作権表示、年、ジャンル、トラック番号の 7 種類だけを通し、それ以外のフレームは止めます。許可したフレームにも、パスやメールアドレスの並びがないかを調べます。APICは、埋め込まれた PNG を Pillow で開き、infoにテキストが残っていれば止めます。check_v3.pyです。
import io, re, sys
from pathlib import Path
from mutagen.id3 import ID3, ID3NoHeaderError
from PIL import Image
ALLOW = {"TIT2", "TPE1", "TALB", "TCOP", "TDRC", "TCON", "TRCK"}
SECRET = re.compile(r"C:\\Users|/home/|[\w.+-]+@[\w-]+\.[a-z]{2,}", re.I)
def inspect(path):
problems = []
try:
tags = ID3(path)
except ID3NoHeaderError:
return problems
for key, frame in tags.items():
name = frame.FrameID
if name == "APIC":
img = Image.open(io.BytesIO(frame.data))
extra = {k: v for k, v in img.info.items() if isinstance(v, (str, bytes))}
if extra:
problems.append(f"APIC内の画像メタデータ: {sorted(extra)}")
elif name not in ALLOW:
problems.append(f"許可外フレーム {key}")
else:
text = " ".join(map(str, frame.text))
if SECRET.search(text):
problems.append(f"{name}に個人情報らしい値")
return problems
if __name__ == "__main__":
for p in sorted(Path(sys.argv[1] if len(sys.argv) > 1 else "samples").glob("*.mp3")):
pr = inspect(p)
print(f"{p.stem:26} {'FAIL' if pr else 'PASS'} {pr[0] if pr else ''}")
python3 check_v3.py
a_clean PASS
b_prompt_in_comment FAIL 許可外フレーム COMM::eng
c_generator_frame FAIL 許可外フレーム TXXX:generator
d_local_path FAIL 許可外フレーム TXXX:source_file
e_cover_with_prompt FAIL APIC内の画像メタデータ: ['parameters']
f_email_in_artist_note FAIL 許可外フレーム COMM::eng
g_lyrics_draft FAIL 許可外フレーム USLT::eng
h_title_has_prompt_word PASS
不備のある 6 件がすべて止まり、問題のない 2 件は通りました。h_title_has_prompt_wordは、単語ではなくフレームの種類を見る方式にしたので通っています。
ただし、この結果には条件があります。許可リストは、販売用の MP3 に入れたいフレームを自分で決められる場合にだけ成り立ちます。タグ編集ソフトが付けた正当な歌詞やコメントも、すべて止めます。実物で何件が止まるかは、まだ数えていません。
取り除く
検査で止まったファイルから、許可外のものだけを外します。
1 回目は、ffmpegでメタデータをまとめて消しました。音声は再エンコードしないので、音質は変わりません。
mkdir clean
for f in samples/*.mp3; do b=$(basename $f); ffmpeg -y -loglevel error -i $f -map 0:a -map_metadata -1 -c copy clean/$b; done
ffprobe -v error -show_entries format_tags -of default=nw=1 clean/a_clean.mp3
TAG:encoder=Lavf61.7.100
不備のないa_cleanまで、曲名、アーティスト、アルバムがすべて消えました。残ったのは、ffmpeg自身が書き込んだエンコーダ名だけです。これをcheck_v3.pyに通します。
python3 check_v3.py clean
a_clean FAIL 許可外フレーム TSSE
b_prompt_in_comment FAIL 許可外フレーム TSSE
c_generator_frame FAIL 許可外フレーム TSSE
d_local_path FAIL 許可外フレーム TSSE
e_cover_with_prompt FAIL 許可外フレーム TSSE
f_email_in_artist_note FAIL 許可外フレーム TSSE
g_lyrics_draft FAIL 許可外フレーム TSSE
h_title_has_prompt_word FAIL 許可外フレーム TSSE
8 件すべてが止まりました。TSSEが許可リストにないためです。販売したい曲の名前まで消えるのも、困ります。ジャケットも消えているか確かめます。
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 clean/e_cover_with_prompt.mp3
audio
-map 0:aで音声だけを残したので、映像ストリームになっていたジャケットは消えています。不備は取れますが、商品としては使えません。
2 回目は、mutagen で許可外のフレームだけを消す形にしました。APICは残して、Pillow で PNG を開き直し、infoを引き継がずに保存し直します。さらに、掃除の前後で音声ストリームの md5 を比べて、音声が変わっていないことを確かめます。clean_v2.pyです。
import io, shutil, subprocess
from pathlib import Path
from mutagen.id3 import ID3, APIC
from PIL import Image
from check_v3 import ALLOW, inspect
SRC, DST = Path("samples"), Path("clean2")
DST.mkdir(exist_ok=True)
def audio_md5(p):
r = subprocess.run(["ffmpeg", "-v", "error", "-i", str(p), "-map", "0:a",
"-c", "copy", "-f", "md5", "-"], capture_output=True, text=True, check=True)
return r.stdout.strip()
for p in sorted(SRC.glob("*.mp3")):
q = DST / p.name
shutil.copy(p, q)
tags = ID3(q)
for key in list(tags.keys()):
fid = tags[key].FrameID
if fid == "APIC":
frame = tags[key]
img = Image.open(io.BytesIO(frame.data)).convert("RGB")
buf = io.BytesIO()
img.save(buf, "PNG") # PILはinfoを引き継がずに保存する
frame.data = buf.getvalue()
elif fid not in ALLOW:
del tags[key]
tags.save(q, v2_version=3)
same = audio_md5(p) == audio_md5(q)
print(f"{p.stem:26} 再検査={'FAIL' if inspect(q) else 'PASS'} 音声一致={same}")
python3 clean_v2.py
a_clean 再検査=PASS 音声一致=True
b_prompt_in_comment 再検査=PASS 音声一致=True
c_generator_frame 再検査=PASS 音声一致=True
d_local_path 再検査=PASS 音声一致=True
e_cover_with_prompt 再検査=PASS 音声一致=True
f_email_in_artist_note 再検査=PASS 音声一致=True
g_lyrics_draft 再検査=PASS 音声一致=True
h_title_has_prompt_word 再検査=PASS 音声一致=True
8 件すべてが再検査を通り、音声も一致しました。ジャケットが残っていて、中のプロンプトが消えているかを、手順の最初と同じ方法で確かめます。
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 clean2/e_cover_with_prompt.mp3
strings -n 8 clean2/e_cover_with_prompt.mp3 | grep -c prompt
audio
video
0
音声と映像(ジャケット)のストリームが残り、stringsで探したプロンプトは 0 件になりました。曲名が「Prompt Jingle」のファイルも、中身は変わっていません。
ffprobe -v error -show_entries format_tags -of default=nw=1 clean2/h_title_has_prompt_word.mp3
TAG:title=Prompt Jingle
TAG:artist=hibanas
TAG:album=BGM Pack Vol.1
TAG:copyright=2026 hibanas. Commercial use allowed.
今回やって失敗したこと
1 回目の検査は、タグがそろっていれば良い商品だと思い込んでいました。見るべきは、あるかどうかではなく、余計なものが入っていないかでした。2 回目は、語を探せば足りると考えて、ジャケットの中と歌詞に手が届きませんでした。掃除は、ffmpegでメタデータをまとめて消す方法を先に選んだので、売りたい曲名まで消えました。しかもffmpegがTSSEを書き足したので、検査を通らない状態になりました。
まだ残る穴も書いておきます。ID3v1 と APEv2、WAV や AAC のタグは見ていません。ジャケットは PNG のテキストチャンクだけを見ています。JPEG の EXIF や、PNG のeXIfは調べていません。許可リストは販売側で決められる場合に限った方式で、実物のタグが何件止まるかは未測定です。音声そのものに、生成時のサービスが埋め込む透かしや署名が入っているかも、この方法では分かりません。
次に変えること
見本ではなく、実際に AI 音楽サービスと画像生成で作ったファイルを数本集めて、同じ検査を通します。何件が止まり、どのフレームに何が入っていたかを数えます。その結果を見てから、許可リストに入れるフレームを決め直します。
よくあるエラー
| 症状 | 原因 | 対処 |
|---|---|---|
mutagen.id3._util.ID3NoHeaderErrorが出ます | MP3にID3タグがありません | except ID3NoHeaderErrorで空のタグとして扱います |
ffprobeにジャケット画像の中身が出ません | 画像はタグではなく映像ストリームとして扱われます | mutagenでAPICを取り出し、Pillowで開いて確かめます |
| 掃除の後に曲名まで消えます | -map_metadata -1は、全部のタグを消します | 許可外のフレームだけをmutagenで消します |
掃除の後にTSSEが検査に落ちます | ffmpegで書き出すと、エンコーダ名が書き足されます | 掃除の最後にmutagenで許可外を消します |
保存後にffprobeのタグが一部しか出ません | ffprobeが名前を付けて表示できるフレームだけを出します | mutagenでID3(path).keys()を見ます |
掃除後にAPICのinfoが残ります | 画像のバイト列を、そのまま書き戻しています | Pillowで開き直して、保存し直します |
出典と検証範囲
| 一次情報 | この回で確認した事項 |
|---|---|
| ID3 tag version 2.3.0 | APIC、USLT、TSSE、ユーザー定義テキストのフレームがあること |
| mutagen: ID3 | ID3の読み書きと、saveのv2_versionの指定があること |
| FFmpeg Documentation | -map_metadataでメタデータのコピー元を指定できること |
| Portable Network Graphics (PNG) Specification (Third Edition) | tEXt、zTXt、iTXtがテキスト情報のチャンクであること |
| Pillow: Image file formats | PNGのPngInfoでテキストチャンクを扱えること |
実測したのは、上の 8 つの手作り見本だけです。音はffmpegが作った 3 秒の正弦波で、AI 音楽サービスが出力した MP3 や、実際の販売ファイルを測ったわけではありません。


