YouTube に上げた自分の動画137本の音量を、YouTube 自身の記録で並べました。8月25日までの87本で、目標(-14 LUFS ±1.5)に入っていたのは6本です。書き出しの最後に ffmpeg で -14 に合わせて測るようにした8月26日からは、50本全部が入っています。
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 まで打って、動画ファイルをターミナルにドラッグします。
フォルダの中を全部調べるなら、こうです。
for f in *.mp4; do python3 video_qc.py "$f"; done
Get-ChildItem *.mp4 | ForEach-Object { python video_qc.py $_.FullName }
書き出しからアップロードまでをスクリプトにしている人は、間に挟むと NG の動画が先へ進みません。
python3 video_qc.py out.mp4 && ./upload.sh out.mp4
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 だけです(ffmpeg と ffprobe に PATH が通っていること)。
#!/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秒の無音、大きすぎて割れる音の順です。
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 では、行の終わりの \ を `(バッククォート)に替えます。
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 は anullsrc の d= に対応していないので、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行で。
音と画の壊れ方は数秒の検査で止まり、中身の誤りは別の目に見せないと止まりませんでした。
参考
- 読み物版(note): https://note.com/views_of_life/n/ne237c6862f51



