不重新编码视频,智能调整视频文件音量
以前遇到这种需要调整视频音量的情况,我可能会使用 Adobe Premiere Pro 为音频轨道加一个 Hard Limiter 的 effect,重新导出,然后用 Handbrake 再编码一次……这样做的时间成本比较高,也很可能因为重新编码而损失视频质量。最近遇到类似问题后和 AI 讨论了一个使用 Python 代码调用 FFmpeg 的方案,就只需要重新编码视频的音频部分,视频部分不受影响。
基本思路
像 *.mp4, *.mkv 这样的视频文件,是用一个容器把视频、音频、字幕等不同的数据包装在一起,所以视频、音频、字幕可以通过 FFmpeg 来分别处理。
使用 AI 生成的这段 Python 脚本,可以调用 FFmpeg 分析音频响度,自动解析 loudnorm 输出的 JSON,用分析结果进行第二遍修正,输出一个视频不重新编码、音量统一的视频文件。
音量调整有 3 个参数,按照 AI 的建议,整体听感响度按照网络视频标准设置为 -16 LUFS,峰值安全上限设置为 -1.5 dBTP 防止削波失真,动态范围约束为 11 LU 控制最大和最小音量之间的差值。如果希望详细了解可以用上一句话去请 AI 展开讲解,也可以根据自己的实际需要修改以上参数。
准备工作
如果对安装步骤有疑问,可以直接问 AI。例如「Windows 11 系统怎样安装 FFmpeg?」
代码主体
将以下代码保存为 .py 文件,例如可以命名为 loudnorm_2pass.py。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def run_capture(cmd: List[str]) -> subprocess.CompletedProcess:
"""
运行命令并捕获 stdout/stderr(用于第一遍抓 JSON)。
注意:我们会显式加 -nostdin(在 cmd 里),避免 ffmpeg 等待交互输入。
"""
print("\n>>", " ".join(cmd))
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def run_passthrough(cmd: List[str]) -> int:
"""
运行命令并将输出直接打印到终端(用于第二遍,让用户看到进度/报错)。
"""
print("\n>>", " ".join(cmd))
p = subprocess.run(cmd)
return p.returncode
def must_exist_executable(name: str) -> None:
if shutil.which(name) is None:
raise SystemExit(f"未找到 {name}。请先安装并确保 {name} 在 PATH 中。")
def resolve_path(p: str) -> str:
return str(Path(p).expanduser().resolve())
def ffprobe_audio_streams(input_path: str) -> Tuple[int, Dict[str, Any]]:
"""
返回 (音频流数量, ffprobe 原始 JSON)。
"""
must_exist_executable("ffprobe")
cmd = [
"ffprobe",
"-hide_banner",
"-v", "error",
"-print_format", "json",
"-show_streams",
"-select_streams", "a",
input_path,
]
r = run_capture(cmd)
if r.returncode != 0:
raise SystemExit(f"ffprobe 失败:\n{r.stderr}")
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
raise SystemExit("ffprobe 输出解析失败(非 JSON)。")
streams = data.get("streams", []) or []
return len(streams), data
def find_last_json_object(text: str) -> Dict[str, Any]:
"""
从 ffmpeg stderr 中提取 loudnorm 的 JSON 块。
采用“取最后一个 { 到最后一个 }”的策略,并验证必须包含 loudnorm 关键字段。
"""
start = text.rfind("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise RuntimeError("未在 FFmpeg 输出中找到 loudnorm JSON。请确认第一遍使用了 print_format=json,且输入包含音频流。")
blob = text[start:end + 1].strip()
try:
obj = json.loads(blob)
except json.JSONDecodeError as e:
raise RuntimeError(f"解析 loudnorm JSON 失败:{e}\nJSON片段:\n{blob}")
# loudnorm JSON 的关键字段校验
for k in ("input_i", "input_tp", "input_lra", "input_thresh"):
if k not in obj:
raise RuntimeError(f"找到的 JSON 不像 loudnorm 输出,缺少字段 {k}。\nJSON片段:\n{blob}")
return obj
raise RuntimeError("未在 FFmpeg 输出中找到 loudnorm JSON。请确认第一遍使用了 print_format=json,且输入包含音频流。")
def main() -> None:
parser = argparse.ArgumentParser(
description="Two-pass EBU R128 loudnorm: copy video, normalize audio; robust non-interactive workflow."
)
parser.add_argument("input", help="Input video file (e.g., input.mp4)")
parser.add_argument("output", help="Output video file (e.g., output.mp4)")
parser.add_argument("--I", default="-16", help="Target integrated loudness (LUFS), default -16")
parser.add_argument("--TP", default="-1.5", help="Target true peak (dBTP), default -1.5")
parser.add_argument("--LRA", default="11", help="Target loudness range, default 11")
parser.add_argument("--audio-index", type=int, default=0, help="Which audio stream index to process, default 0 (i.e., 0:a:0)")
parser.add_argument("--audio-codec", default="aac", help="Audio codec for output, default aac")
parser.add_argument("--audio-bitrate", default="192k", help="Audio bitrate, default 192k")
parser.add_argument("--keep-subtitles", action="store_true", help="Copy subtitle streams if present (0:s?)")
parser.add_argument("--no-overwrite", action="store_true", help="Do not overwrite output if it exists (default: overwrite with -y)")
parser.add_argument("--max-muxing-queue-size", type=int, default=1024, help="Set FFmpeg -max_muxing_queue_size, default 1024")
args = parser.parse_args()
must_exist_executable("ffmpeg")
must_exist_executable("ffprobe")
inp = resolve_path(args.input)
out = resolve_path(args.output)
# 0) 预检查:音频流是否存在
audio_count, _ = ffprobe_audio_streams(inp)
if audio_count == 0:
raise SystemExit("输入文件未检测到任何音频流(audio stream)。无法进行 loudnorm 处理。")
if args.audio_index < 0 or args.audio_index >= audio_count:
raise SystemExit(f"--audio-index={args.audio_index} 超出范围:此文件音频流数量为 {audio_count}(有效范围 0..{audio_count-1})。")
if audio_count > 1:
print(f"提示:检测到 {audio_count} 条音频流。本脚本默认只处理 0:a:{args.audio_index}(可用 --audio-index 指定)。")
# 输出文件覆盖策略
if Path(out).exists() and args.no_overwrite:
raise SystemExit(f"输出文件已存在且启用了 --no-overwrite:{out}")
# 1) Pass 1: analysis
analysis_filter = f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:print_format=json"
cmd1 = [
"ffmpeg",
"-hide_banner",
"-nostdin", # 关键:禁止交互输入,避免“看似卡死”
"-i", inp,
"-map", f"0:a:{args.audio_index}",
"-filter:a", analysis_filter,
"-f", "null", "-"
]
r1 = run_capture(cmd1)
if r1.returncode != 0:
print(r1.stderr, file=sys.stderr)
raise SystemExit("第一遍分析失败。")
info = find_last_json_object(r1.stderr)
# 必要字段
measured_I = info.get("input_i")
measured_TP = info.get("input_tp")
measured_LRA = info.get("input_lra")
measured_thresh = info.get("input_thresh")
print("\n=== Pass1 measurement ===")
print(json.dumps(info, ensure_ascii=False, indent=2))
# 2) Pass 2: normalization
norm_filter = (
f"loudnorm=I={args.I}:TP={args.TP}:LRA={args.LRA}:"
f"measured_I={measured_I}:measured_TP={measured_TP}:"
f"measured_LRA={measured_LRA}:measured_thresh={measured_thresh}"
)
cmd2 = [
"ffmpeg",
"-hide_banner",
"-nostdin",
"-y" if not args.no_overwrite else "-n", # -y 覆盖;-n 不覆盖
"-i", inp,
"-map", "0:v",
"-map", f"0:a:{args.audio_index}",
"-c:v", "copy",
"-filter:a", norm_filter,
"-c:a", args.audio_codec,
"-b:a", args.audio_bitrate,
"-max_muxing_queue_size", str(args.max_muxing_queue_size),
"-movflags", "+faststart"
]
if args.keep_subtitles:
cmd2 += ["-map", "0:s?"] # 若存在字幕流则复制
cmd2 += [out]
# 第二遍建议直通输出,用户能看到进度与报错
rc2 = run_passthrough(cmd2)
if rc2 != 0:
raise SystemExit("第二遍处理失败。")
print("\n完成。输出文件:", out)
if __name__ == "__main__":
main()运行方式
在命令行中执行以下代码
python loudnorm_2pass.py input.mp4 output.mp4
注意将 Python 脚本、输入输出视频文件修改为正确的路径,例如
python "D:\loudnorm_2pass.py" "D:\Cuts\TownScaper.mp4" "D:\Cuts\TownScaper_2.mp4"注意 output.mp4 这部分是新生成的视频文件的路径,如果该文件已经存在将被覆盖。
如果视频文件有多个音轨,可以加入 –audio-index 参数运行。




