使用 FFmpeg 安全批量将 MP3 转为 MP4(封面或纯色)

这份维护版指南会把一个目录里的每个 MP3 转成 MP4,并为其配上静态封面或自动生成的纯色画面。脚本面向 GNU/Linux 上的 Bash;它不解析 ls 输出,因此能正确处理空格、制表符、换行以及大小写混合的 .mp3 扩展名。

脚本会刻意新建一个此前不存在的输出目录。每个候选文件先写进目标文件系统内的临时目录,通过 ffprobe 验证后才改成最终名称。输入文件不会被修改。即使如此,也请保留备份:转换结果不应成为音频的唯一副本。

先判断是否真的需要视频

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。第三个参数用于选择 covercolor。封面可以是奇数尺寸:滤镜会把它等比例适配到 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.mp3song.MP3 这类重复主文件名报错,而不是悄悄替换结果。
  • find ... -print0 配合 read -d '' 能完整保留任意文件名。解析 ls 无法安全处理包含空白或换行的文件名。
  • .partial.mp4 位于目标目录之下,因此最终 mv 不会跨文件系统。候选文件失败时由子 shell 的退出 trap 清理。
  • 脚本不会递归。只有在同时设计好子目录和重复文件名如何映射到输出端后,才应移除 -maxdepth 1
  • ffmpeg -n 会拒绝覆盖临时候选文件,最终路径还会检查两次。除非无人值守任务确实应该覆盖文件,否则不要改成 -y

官方一手文档

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
`
~~~

Leave a Reply