这份维护版指南会把一个目录里的每个 MP3 转成 MP4,并为其配上静态封面或自动生成的纯色画面。脚本面向 GNU/Linux 上的 Bash;它不解析 ls 输出,因此能正确处理空格、制表符、换行以及大小写混合的 .mp3 扩展名。
脚本会刻意新建一个此前不存在的输出目录。每个候选文件先写进目标文件系统内的临时目录,通过 ffprobe 验证后才改成最终名称。输入文件不会被修改。即使如此,也请保留备份:转换结果不应成为音频的唯一副本。
Table of Contents
先判断是否真的需要视频
MP4 是容器,不代表音质会得到改善。只有当目标平台要求视频流时,添加静止画面才有意义。如果目标允许只上传音频,M4A 更简单、也更小,因为它没有虚构的视频轨:
ffmpeg -nostdin -hide_banner -loglevel error -n
-i "input.mp3" -map 0:a:0 -map_metadata 0
-c:a aac -b:a 192k -movflags +faststart "output.m4a"
把有损 MP3 转成有损 AAC 无法恢复音质。请保留原始 MP3,并根据源文件和接收端选择 AAC 码率,不要以为调高数值就能改善原音频。
安全批处理脚本:封面图或生成纯色
把下面内容保存为 mp3-to-mp4.sh,然后运行 chmod +x mp3-to-mp4.sh。第三个参数用于选择 cover 或 color。封面可以是奇数尺寸:滤镜会把它等比例适配到 1280×720 以内,再补齐到常见 H.264/yuv420p 工作流所需的偶数尺寸,并设置为方形像素。
#!/usr/bin/env bash
set -Eeuo pipefail
input_dir=${1:?"usage: $0 INPUT_DIR NEW_OUTPUT_DIR [cover|color] [COVER_IMAGE]"}
output_dir=${2:?"usage: $0 INPUT_DIR NEW_OUTPUT_DIR [cover|color] [COVER_IMAGE]"}
mode=${3:-cover}
cover=${4:-cover.png}
[[ -d "$input_dir" ]] || { printf 'not a directory: %sn' "$input_dir" >&2; exit 2; }
[[ ! -e "$output_dir" ]] || {
printf 'output must be a fresh path: %sn' "$output_dir" >&2
exit 2
}
case "$mode" in
cover)
[[ -f "$cover" ]] || { printf 'cover not found: %sn' "$cover" >&2; exit 2; }
video_args=(-loop 1 -framerate 1 -i "$cover")
video_filter='scale=1280:720:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2:(ow-iw)/2:(oh-ih)/2,setsar=1'
video_title='Cover image'
;;
color)
video_args=(-f lavfi -i 'color=c=black:s=1280x720:r=1')
video_filter='setsar=1'
video_title='Generated black frame'
;;
*)
printf 'mode must be cover or color: %sn' "$mode" >&2
exit 2
;;
esac
# Plain mkdir intentionally fails if another process creates the path first.
mkdir -- "$output_dir"
found=false
while IFS= read -r -d '' input; do
found=true
base=${input##*/}
stem=${base%.*}
final="$output_dir/$stem.mp4"
[[ ! -e "$final" ]] || { printf 'refusing to overwrite: %sn' "$final" >&2; exit 3; }
workdir=$(mktemp -d "$output_dir/.ffmpeg-work.XXXXXX")
partial="$workdir/output.partial.mp4"
(
trap 'rm -f -- "$partial"; rmdir -- "$workdir" 2>/dev/null || true' EXIT
ffmpeg -nostdin -hide_banner -loglevel error -n
"${video_args[@]}" -i "$input"
-map 0:v:0 -map 1:a:0 -map_metadata 1
-vf "$video_filter"
-c:v libx264 -preset medium -tune stillimage -crf 23 -pix_fmt yuv420p
-c:a aac -b:a 192k
-metadata:s:v:0 handler_name="$video_title"
-metadata:s:a:0 handler_name="$stem"
-shortest -movflags +faststart "$partial"
video_codec=$(ffprobe -v error -select_streams v:0
-show_entries stream=codec_name -of default=nw=1:nk=1 "$partial")
audio_codec=$(ffprobe -v error -select_streams a:0
-show_entries stream=codec_name -of default=nw=1:nk=1 "$partial")
video_count=$(ffprobe -v error -select_streams v
-show_entries stream=index -of csv=p=0 "$partial" | awk 'END { print NR + 0 }')
audio_count=$(ffprobe -v error -select_streams a
-show_entries stream=index -of csv=p=0 "$partial" | awk 'END { print NR + 0 }')
duration=$(ffprobe -v error -show_entries format=duration
-of default=nw=1:nk=1 "$partial")
[[ "$video_codec" == h264 && "$audio_codec" == aac ]]
[[ "$video_count" == 1 && "$audio_count" == 1 ]]
awk -v d="$duration" 'BEGIN { exit !(d + 0 > 0) }'
# The temporary file and destination share a filesystem, so this rename is atomic.
[[ ! -e "$final" ]] || { printf 'refusing to overwrite: %sn' "$final" >&2; exit 3; }
mv -- "$partial" "$final"
rmdir -- "$workdir"
trap - EXIT
)
printf 'created: %sn' "$final"
done < <(find "$input_dir" -maxdepth 1 -type f -iname '*.mp3' -print0)
[[ "$found" == true ]] || { printf 'no MP3 files found in %sn' "$input_dir" >&2; exit 4; }
使用封面图:
./mp3-to-mp4.sh ./input ./output-cover cover ./cover.png
使用自动生成的黑色画面时,请指定另一个全新的输出目录:
./mp3-to-mp4.sh ./input ./output-color color
显式的 -map 选项只选择一个视频流和一个音频流。-map_metadata 1 从 MP3 输入复制全局元数据,两个流级 handler_name 选项则为轨道命名。-shortest 会在有限时长的音频结束时停止,+faststart 把 MP4 初始化元数据移到文件前部,方便渐进式下载。
为什么默认使用 AAC
某些 MP4 工作流可以承载 MP3 音频,但接收端是否支持并不确定。MP4 搭配 AAC 的互操作性更好。如果已知目标明确接受 MP4 内的 MP3 音频,而且避免再次有损编码很重要,可以把 -c:a aac -b:a 192k 替换为 -c:a copy,再用目标系统实际测试,并执行:
audio_codec=$(ffprobe -v error -select_streams a:0
-show_entries stream=codec_name -of default=nw=1:nk=1 "output.mp4")
[[ "$audio_codec" == mp3 ]] || { printf 'unexpected audio codec: %sn' "$audio_codec" >&2; exit 1; }
封装成功并不等于一定能播放。必须用真正接收文件的浏览器、应用、剪辑系统或上传服务验证。
可复现的冒烟测试
下面的测试会生成一段短正弦波 MP3 和一张奇数尺寸封面,并使用同时含空格与换行的文件名,再分别运行两种模式。请在已保存脚本的目录里执行:
set -Eeuo pipefail
test_root=$(mktemp -d)
trap 'rm -rf -- "$test_root"' EXIT
mkdir -- "$test_root/input"
fixture="$test_root/input/"$'tone onenline.mp3'
ffmpeg -nostdin -hide_banner -loglevel error
-f lavfi -i 'sine=frequency=440:duration=1.5'
-c:a libmp3lame -q:a 4 "$fixture"
ffmpeg -nostdin -hide_banner -loglevel error
-f lavfi -i 'color=c=navy:s=641x359' -frames:v 1 "$test_root/cover.png"
./mp3-to-mp4.sh "$test_root/input" "$test_root/output-cover" cover "$test_root/cover.png"
./mp3-to-mp4.sh "$test_root/input" "$test_root/output-color" color
ffprobe -v error -show_entries stream=index,codec_type,codec_name,width,height
-show_entries format=duration -of json
"$test_root/output-cover/"$'tone onenline.mp4'
预期结果是一个 H.264 视频流、一个 AAC 音频流、正数时长以及偶数宽高。脚本会在最终文件名可见之前完成这些机器检查;末尾的 JSON 则便于人工复核。
运行注意事项
- 输出目录必须尚不存在。这样能防止误用旧目录,也会让
song.mp3与song.MP3这类重复主文件名报错,而不是悄悄替换结果。 find ... -print0配合read -d ''能完整保留任意文件名。解析ls无法安全处理包含空白或换行的文件名。.partial.mp4位于目标目录之下,因此最终mv不会跨文件系统。候选文件失败时由子 shell 的退出 trap 清理。- 脚本不会递归。只有在同时设计好子目录和重复文件名如何映射到输出端后,才应移除
-maxdepth 1。 ffmpeg -n会拒绝覆盖临时候选文件,最终路径还会检查两次。除非无人值守任务确实应该覆盖文件,否则不要改成-y。
官方一手文档
- FFmpeg 命令行语法、流选择与 `-map`
- FFmpeg 视频滤镜,包括 `color`、`scale`、`pad` 与 `setsar`
- FFmpeg MOV/MP4 复用器选项,包括 `faststart`
- FFmpeg AAC 编码器文档
- ffprobe 文档
2017/2023 年原始导出(逐字存档)
上面的维护版指南替换了不安全的文件名解析方式,并加入兼容性与验证边界。为保留出处,下面完整保存仓库中这篇 2017 年文章的 2023 年导出可见正文。仅统一换行和行末空白;这些命令属于历史存档,不应优先于维护版复制使用。
~~~markdown
Solution 1:
`ls *.mp3 | while read mp3File ; do outputFile=$(basename "${mp3File}" .mp3) ; ffmpeg -i "${mp3File}" -loop 1 -i image.png -c:a copy -c:v libx264 -shortest "${outputFile}".mp4 ; done
`
Solution 2:
`
$ mkdir out
$ for f in *.mp3; do ffmpeg -f lavfi -i color=s=160x120:r=2 -i "$f"
-c:v libx264 -preset ultrafast -c:a copy -shortest
out/"${f%.mp3}.mp4"; done
`
~~~
