0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動画の音量合わせと公開前チェックを ffmpeg で自動化したら、50本連続で目標内になった【コピペで動く】

0
Last updated at Posted at 2026-09-23

YouTube に上げた自分の動画137本の音量を、YouTube 自身の記録で並べました。8月25日までの87本で、目標(-14 LUFS ±1.5)に入っていたのは6本です。書き出しの最後に ffmpeg で -14 に合わせて測るようにした8月26日からは、50本全部が入っています。

公開中の動画137本の音量。8月26日を境に、全部が目標の帯に入った

LUFS は、人の耳の感じ方に合わせた音の大きさの単位です。-30 のままだと、前後の動画より小さく聞こえます。YouTube は大きすぎる音は下げますが、小さい音を目標まで上げてはくれません。

「測る」ほうを、どの動画にもかけられる Python 1ファイルにまとめ直しました。音と画面の6項目を数秒で測り、1つでも NG なら終了コード1で止まります。手元に残っていた昔の動画27本にかけると、27本とも音量が NG。値は YouTube の記録と0.06以内で一致しました。

使うとこうなる

公開中の51.5秒のショートにかけると、約3秒でこう出ます(Core i7-14700F)。

$ python video_qc.py ai_one_15.mp4
ai_one_15.mp4  51.5秒 / 1080x1920
OK  音の大きさ   -14.9 LUFS(目標 -14±1.5)
OK  音割れ     ピーク -1.7 dBTP(-1 以下)
OK  無音の穴    なし
OK  固まった画面  なし
OK  黒いコマ    なし
OK  冒頭の暗さ   明るさ 37/255(20 以上)
→ 公開してよい

冒頭を黒にして、4秒固まらせ、2秒の無音と割れる音を入れた8秒の動画です。4か所壊すと、6項目が NG になります。

$ python video_qc.py broken.mp4
broken.mp4  8.0秒 / 1080x1920
NG  音の大きさ   -1.2 LUFS(目標 -14±1.5)
NG  音割れ     ピーク 0.0 dBTP(-1 以下)
NG  無音の穴    あり: 3.0秒〜
NG  固まった画面  あり: 1.0秒〜
NG  黒いコマ    あり: 0.0秒〜
NG  冒頭の暗さ   明るさ 16/255(20 以上)
→ NG が 6 件。直してから公開

無音・固まり・黒いコマには何秒からかが付くので、編集ソフトでそこへ飛べば直せます。3分19秒の横動画なら約10秒、3分41秒なら約15秒です。

入れ方と使い方

OS ffmpeg を入れる 実行
Windows winget install Gyan.FFmpeg(入れたらターミナルを開き直す) python video_qc.py 動画.mp4
Mac brew install ffmpeg python3 video_qc.py 動画.mp4
Ubuntu など sudo apt install ffmpeg python3 video_qc.py 動画.mp4

パスを打つのが面倒なら、python video_qc.py まで打って、動画ファイルをターミナルにドラッグします。

フォルダの中を全部調べるなら、こうです。

bash / zsh
for f in *.mp4; do python3 video_qc.py "$f"; done
PowerShell
Get-ChildItem *.mp4 | ForEach-Object { python video_qc.py $_.FullName }

書き出しからアップロードまでをスクリプトにしている人は、間に挟むと NG の動画が先へ進みません。

bash / zsh
python3 video_qc.py out.mp4 && ./upload.sh out.mp4
PowerShell(5.1 でも動く書き方)
python video_qc.py out.mp4; if ($LASTEXITCODE -eq 0) { .\upload.ps1 out.mp4 }

PowerShell 5.1 で結果をファイルに保存すると文字化けします。先に [Console]::OutputEncoding = [Text.Encoding]::UTF8 を打ってから保存してください。画面に出すだけなら、そのままで化けません。

スクリプト

要るのは Python 3.8 以上と ffmpeg だけです(ffmpegffprobe に PATH が通っていること)。

video_qc.py
#!/usr/bin/env python3
"""書き出した動画を、公開前に自動で検査する(ffmpeg だけで動く)。

  python video_qc.py 動画.mp4        (Mac / Linux は python3)

  見るもの: 音の大きさ・音割れ・無音の穴・固まった画面・黒いコマ・冒頭の暗さ
  1つでも NG があれば終了コード 1 を返す(書き出しの後に続けて置ける)
  必要なもの: Python 3.8+ と ffmpeg / ffprobe(PATH が通っていること)
"""
import json, os, re, shutil, subprocess, sys

for _s in (sys.stdout, sys.stderr):
    if hasattr(_s, "reconfigure"):
        _s.reconfigure(encoding="utf-8", errors="replace")   # Git Bash などでも日本語を崩さない

# 基準(BGM のある YouTube 動画向けの目安。好みで変える)
LUFS_TARGET, LUFS_TOL = -14.0, 1.5   # 音の大きさ。YouTube の目標は -14。大きすぎると下げられ、小さすぎても目標までは上がらない
PEAK_MAX = -1.0                      # True Peak(dBTP)。0 に近いと再エンコードで割れやすい
SILENCE_DB, SILENCE_SEC = -50, 1.5   # この音量以下が、この秒数続いたら「無音の穴」
FREEZE_SEC = 3.0                     # 画面がこの秒数以上まったく変わらなければ「固まり」
BLACK_SEC = 0.1                      # 黒いコマ(画面の98%以上がほぼ黒)がこの秒数以上続いたら NG
FIRST_Y_MIN = 20                     # 0.5秒時点の平均の明るさ(黒=16、白=235)。ほぼ真っ黒で始まる動画を止める
END_GRACE = 2.0                      # 最後のこの秒数に始まる無音・黒はフェードアウトとみなして数えない


def ff(args):
    """ffmpeg を走らせて (終了コード, ログ) を返す"""
    r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", *args], capture_output=True, text=True,
                       encoding="utf-8", errors="replace")
    return r.returncode, r.stderr


def num(s):
    return float("-inf") if s.endswith("inf") and s.startswith("-") else float(s)


def found(times, dur):
    """フェードアウトの分を除いて、見つかった時刻を返す(長さが分からない動画は全部数える)"""
    return [t for t in times if dur <= 0 or t < dur - END_GRACE]


def main(path):
    if not (shutil.which("ffmpeg") and shutil.which("ffprobe")):
        print("NG  ffmpeg / ffprobe が見つからない(入れたあと、ターミナルを開き直す)"); return 1
    pr = subprocess.run(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
                        capture_output=True, text=True, encoding="utf-8", errors="replace")
    if pr.returncode != 0 or not pr.stdout.strip():
        print("NG  動画を読めない(パスか形式を確かめる)"); return 1
    info = json.loads(pr.stdout)
    dur = float(info["format"].get("duration") or 0)
    v = next((s for s in info["streams"] if s["codec_type"] == "video"), None)
    if v is None:
        print("NG  映像トラックがない(音声だけのファイル?)"); return 1
    a = next((s for s in info["streams"] if s["codec_type"] == "audio"), None)
    has_audio = a is not None
    vw, vh = v.get("width", 0), v.get("height", 0)
    rot = [d.get("rotation", 0) for d in v.get("side_data_list", []) if "rotation" in d] or [v.get("tags", {}).get("rotate", 0)]
    if abs(int(float(rot[0]))) % 180 == 90:
        vw, vh = vh, vw                                # スマホの縦動画(回転の情報つき)
    print(f'{os.path.basename(path)}  {dur:.1f}秒 / {vw}x{vh}')
    rows, logs = [], []

    if has_audio:
        rc, log = ff(["-i", path, "-vn", "-af", "ebur128=peak=true", "-f", "null", "-"]); logs.append(log)
        i = re.findall(r"I:\s+(-?(?:[\d.]+|inf)) LUFS", log)
        p = re.findall(r"Peak:\s+(-?(?:[\d.]+|inf)) dBFS", log)
        if rc or not i or not p:
            rows.append(("音の大きさ", "解析できない", False))
        else:
            lufs, peak = num(i[-1]), num(p[-1])
            rows.append(("音の大きさ", f"{lufs:.1f} LUFS(目標 {LUFS_TARGET:g}±{LUFS_TOL:g}", abs(lufs - LUFS_TARGET) <= LUFS_TOL))
            rows.append(("音割れ", f"ピーク {peak:.1f} dBTP({PEAK_MAX:g} 以下)", peak <= PEAK_MAX))
        rc, log = ff(["-i", path, "-vn", "-af", f"silencedetect=n={SILENCE_DB}dB:d={SILENCE_SEC}", "-f", "null", "-"])
        holes = [num(t) for t in re.findall(r"silence_start: (-?[\d.]+)", log)]
        a_end = float(a.get("duration") or 0)
        if a_end and dur - a_end >= SILENCE_SEC:
            holes.append(a_end)                         # 音声が映像より先に終わっている
        holes = found(sorted(holes), dur)
        rows.append(("無音の穴", "解析できない" if rc else ("なし" if not holes else "あり: " + ", ".join(f"{t:.1f}秒〜" for t in holes[:5])), not rc and not holes))
    else:
        rows.append(("", "音声トラックがない", False))

    rc, log = ff(["-i", path, "-an", "-vf", f"freezedetect=n=-60dB:d={FREEZE_SEC}", "-f", "null", "-"]); logs.append(log)
    frz = [num(t) for t in re.findall(r"freeze_start: (-?[\d.]+)", log)]
    rows.append(("固まった画面", "解析できない" if rc else ("なし" if not frz else "あり: " + ", ".join(f"{t:.1f}秒〜" for t in frz[:5])), not rc and not frz))

    rc, log = ff(["-i", path, "-an", "-vf", f"blackdetect=d={BLACK_SEC}:pix_th=0.10", "-f", "null", "-"])
    blk = found([num(t) for t in re.findall(r"black_start:(-?[\d.]+)", log)], dur)
    rows.append(("黒いコマ", "解析できない" if rc else ("なし" if not blk else "あり: " + ", ".join(f"{t:.1f}秒〜" for t in blk[:5])), not rc and not blk))

    # 10bit の動画でも 0〜255 で測れるよう、8bit に直してから明るさを取る
    rc, log = ff(["-ss", "0.5", "-i", path, "-an", "-frames:v", "1", "-vf", "format=yuv420p,signalstats,metadata=print", "-f", "null", "-"])
    y = re.findall(r"lavfi\.signalstats\.YAVG=([\d.]+)", log)
    first = float(y[0]) if y else 0.0
    rows.append(("冒頭の暗さ", f"明るさ {first:.0f}/255({FIRST_Y_MIN} 以上)" if y else "測れない(0.5秒より短い?)", bool(y) and first >= FIRST_Y_MIN))
    # 途中で切れたファイルなど、読むときにエラーが出たら知らせる(ふだんは表示しない)
    if any(re.search(r"partial file|corrupt|Invalid data found|Error while decoding", g) for g in logs):
        rows.append(("壊れたデータ", "あり(書き出し直す)", False))

    w = max(len(r[0]) for r in rows)
    for name, val, ok in rows:
        print(f"{'OK' if ok else 'NG'}  {name}{' ' * (w - len(name))}  {val}")
    bad = sum(1 for r in rows if not r[2])
    print("→ 公開してよい" if bad == 0 else f"→ NG が {bad} 件。直してから公開")
    return 1 if bad else 0


if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("使い方: python video_qc.py 動画.mp4")
    sys.exit(main(sys.argv[1]))

壊れた動画で試す

壊れた動画が手元になくても、ffmpeg で作れます。冒頭1秒が黒、4秒固まる青い画面、3秒の動く画面。音は、普通の音、2秒の無音、大きすぎて割れる音の順です。

bash / zsh
ffmpeg -y \
 -f lavfi -i "color=c=black:s=1080x1920:r=30:d=1" \
 -f lavfi -i "color=c=0x2a78d6:s=1080x1920:r=30:d=4" \
 -f lavfi -i "testsrc2=s=1080x1920:r=30:d=3" \
 -f lavfi -i "sine=frequency=440:sample_rate=48000:d=3" \
 -f lavfi -i "anullsrc=r=48000:cl=stereo:d=2" \
 -f lavfi -i "sine=frequency=220:sample_rate=48000:d=3" \
 -filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v];[3:a]volume=0.5,aformat=channel_layouts=stereo[a1];[5:a]volume=12,aformat=channel_layouts=stereo[a3];[a1][4:a][a3]concat=n=3:v=0:a=1[a]" \
 -map "[v]" -map "[a]" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest broken.mp4

PowerShell では、行の終わりの \`(バッククォート)に替えます。

PowerShell
ffmpeg -y `
 -f lavfi -i "color=c=black:s=1080x1920:r=30:d=1" `
 -f lavfi -i "color=c=0x2a78d6:s=1080x1920:r=30:d=4" `
 -f lavfi -i "testsrc2=s=1080x1920:r=30:d=3" `
 -f lavfi -i "sine=frequency=440:sample_rate=48000:d=3" `
 -f lavfi -i "anullsrc=r=48000:cl=stereo:d=2" `
 -f lavfi -i "sine=frequency=220:sample_rate=48000:d=3" `
 -filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v];[3:a]volume=0.5,aformat=channel_layouts=stereo[a1];[5:a]volume=12,aformat=channel_layouts=stereo[a3];[a1][4:a][a3]concat=n=3:v=0:a=1[a]" `
 -map "[v]" -map "[a]" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest broken.mp4

これにかけると、上の NG 6件がそのまま出ます。Ubuntu 20.04 の ffmpeg 4.2 は anullsrcd= に対応していないので、22.04 以降で試してください。

何を見ているか

項目 使うフィルタ NG にする条件 メモ
音の大きさ ebur128 -14±1.5 LUFS の外 YouTube の目標は -14。詳細統計情報に tgt.-14.0dB と出る
音割れ ebur128=peak=true True Peak が -1.0 dBTP を超える 割れる手前の余裕を見る。すでに割れた音を小さくしたものは素通りする
無音の穴 silencedetect -50dB 以下が1.5秒続く。音声が映像より1.5秒以上早く終わる BGM の入れ忘れがここで出る
固まった画面 freezedetect=n=-60dB 画面のほぼ全体が3秒以上変わらない 立ち絵の口パクやカーソルだけが動く場面も「固まった」と出る
黒いコマ blackdetect=pix_th=0.10 画面の98%以上が明るさ約38/255以下のコマが、0.1秒以上続く 真っ黒でなくても、とても暗いタイトル画面で出ることがある
冒頭の暗さ signalstats 0.5秒時点の平均の明るさが20未満 黒は16(リミテッドレンジ)。ほぼ真っ黒で始まるときだけ止まる

ほかに、途中で切れたファイルのように読むときにエラーが出たら「壊れたデータ」の NG が足されます。フェードアウトを NG にしないよう、最後の2秒に始まる黒と無音は数えていません(END_GRACE)。10bit の動画は、8bit に直してから明るさを測ります(そのままだと 0〜1023 の値が返ってきます)。

ずんだもんのような立ち絵の動画では、口パクだけの場面が「固まった画面」に出ます。手元の昔の動画27本では12本がこれで NG でした。ショートで3秒以上ほとんど動かないのは離脱のもとなので、私は NG のままにしています。わざと止めているなら無視してかまいません。

NG が出たら

音の大きさは、ffmpeg の1行で -14 に寄せられます(bash でも PowerShell でも同じ)。

ffmpeg -i in.mp4 -af "loudnorm=I=-14:TP=-2,loudnorm=I=-14:TP=-2" -ar 48000 -c:v copy -c:a aac -b:a 192k out.mp4

同じ loudnorm を1行に2つ並べています(ffmpeg でいう「2パス」とは別です)。1つだけだと、-30.5 のショートが -15.7 で止まりました。2つなら3本とも入ります。

動画 直す前 loudnorm 1つ 2つ(上の1行)
AIの実例5選(4月のショート) -30.5 LUFS / -8.9 dBTP -15.7 / -2.0(音の大きさ NG) -14.8 / -2.0
ニューロシンボリックAI(4月、3分33秒) -20.8 / -0.6 -15.4 / -1.8 -14.8 / -1.8
わざと壊した動画 -1.2 / 0.0 -13.3 / -9.3 -13.4 / -9.4

TP=-2 は、AAC に書き出したあとも -1 を超えないための余裕です。ビットレートを指定しない AAC(ffmpeg の既定)で TP=-1.5 にしたら、書き出したあとで -0.1 dBTP まで上がりました。

音割れは、下げても直りません。上の表の3本目はピークが -9.4 まで下がって「音割れ OK」になりますが、割れた波形は割れたままです。書き出す前の音量から下げ直してください。

無音・固まり・黒いコマは、出た秒数の場所を編集ソフトで見て直します。

YouTube の記録と照合した

最初の図の値は、各動画の公開ページに入っている perceptualLoudnessDb です。手元にファイルが残っていた昔の7本と、今日のショート1本で、このスクリプトの値と比べました。

動画 video_qc.py YouTube の記録
AI Washing(4月のショート) -30.2 -30.19
ニューロシンボリックAI(4月、3分33秒) -20.8 -20.76
AIの実例5選(4月のショート) -30.5 -30.51
個人ホームページ(5月のショート) -28.8 -28.86
Claude Dispatch(3月のショート) -30.4 -30.41
xAI の提訴(4月のショート) -30.2 -30.26
英語本80冊(4月のショート) -30.8 -30.83
上限リセットの使い方(9月のショート、上の OK の例) -14.9 -14.87

差はどれも0.06以内でした。YouTube も、ITU-R BS.1770 の積分ラウドネス(ebur128 と同じもの)で測っているようです。

同じ値は、動画の上で右クリック →「詳細統計情報」の Volume / Normalized 行でも見られます。ただし DRC と出ているときは、プレーヤーの「一定音量」(歯車の中。最初からオン)でならしたあとの値です。4月のショートは、オンだと cont.-16.8dB、オフにすると cont.-30.2dB でした。-20.8 の縦動画には、オンでも DRC が付きませんでした。オフで見ると、大きすぎる動画は 100%/43% のように下げられ、小さすぎる動画は 100%/100% のまま、つまり上げられていないことも分かります。

このスクリプトでは止まらないもの

見ているのは、音と画の壊れ方だけです。上の OK の例のショートも公開前に主な10件を直していますが、このスクリプトが見つけたのは0件でした。1件は字幕の位置の検査(画面を HTML で組んでいるので、Playwright で0.2秒ごとに全部の文字の位置を測る)、9件は実物を別のAIに見せて見つかっています。

直した10件の一覧
# 失敗 見つけたもの
1 名前をぼかしたら、押す場所まで真っ白になった 別のAI(動画の審査)
2 読み上げの間、画面の下半分が約5秒ほぼ動かなかった 別のAI(動画の審査)
3 字幕の最後の「る。」「復。」だけが次の行に落ちた 字幕の位置の検査
4 サムネを一覧の大きさにすると、見出し以外の字が読めない(40点中17点) 別のAI(サムネの審査)
5 最初のコマに題字が出ていない 別のAI(動画の審査)
6 見出し・大きなラベル・字幕で、同じことを3回言っていた 別のAI(動画の審査)
7 「5時間と週の上限が両方戻る」と言い切っていた(私の画面でそう出ただけ) 別のAI(事実の審査)
8 「Claude Code では押せない」の見出しと札に「今は」「端末・IDE」が抜けていた 別のAI(事実の審査)
9 期限を「10月23日まで」と書いた(画面は「10月23日に期限切れ」) 別のAI(事実の審査)
10 近道の URL を、開いて確かめないまま書いていた 別のAI(事実の審査)

押す場所が、ぼかしで消えていた

字幕の最後の「る。」だけが次の行に落ちた

言い過ぎていた

事実の審査に渡した指示の骨組みはこうです。実際には、これに一次資料・対象のファイル・気になっている箇所を足して渡しました。

あなたは事実確認の担当です。次の台本・字幕・説明文を、一次資料(公式発表・ヘルプの原文)と突き合わせてください。
良い点は書かず、問題だけを表で出してください(該当の文/何が違うか/根拠の原文/直し方/重大度 高・中・低)。
特に「引用は本物でも使い方が言い過ぎ」を探してください。条件の違う数字をつなぐ、内訳の一部だけを書く、書いていない意味を足す。
最後に「公開を止めるべき誤りがあるか」を1行で。

音と画の壊れ方は数秒の検査で止まり、中身の誤りは別の目に見せないと止まりませんでした。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?