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

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音声の圧縮形式です。この回では、音声データの前にタグが付いたファイルを指します
ID3v2MP3の先頭に付けるタグの形式です。複数のフレームを並べて持ちます
フレームID3v2の1項目です。4文字のIDで種類が決まります。TIT2は曲名です
COMMコメントのフレームです。作業メモが入りやすい場所です
TXXX利用者が名前を付けて作る、独自のテキストフレームです
USLT歌詞や書き起こしのテキストを入れるフレームです
APIC画像を埋め込むフレームです。ジャケットに使います
TSSE保存に使ったソフトや設定を入れるフレームです。ffmpegはLavfの版を書き込みます
ffprobeメディアの情報を表示するコマンドです。ffmpegに付いてきます
mutagenPythonでID3タグを読み書きするライブラリです
PillowPythonの画像ライブラリです。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.0APIC、USLT、TSSE、ユーザー定義テキストのフレームがあること
mutagen: ID3ID3の読み書きと、saveのv2_versionの指定があること
FFmpeg Documentation-map_metadataでメタデータのコピー元を指定できること
Portable Network Graphics (PNG) Specification (Third Edition)tEXt、zTXt、iTXtがテキスト情報のチャンクであること
Pillow: Image file formatsPNGのPngInfoでテキストチャンクを扱えること

実測したのは、上の 8 つの手作り見本だけです。音はffmpegが作った 3 秒の正弦波で、AI 音楽サービスが出力した MP3 や、実際の販売ファイルを測ったわけではありません。

この記事をシェア