FFmpeg + Java 完整教程

目录

  1. FFmpeg 简介
  2. 安装 FFmpeg
  3. 常用命令
  4. Java 集成方式
  5. 实战案例

1. FFmpeg 简介

FFmpeg 是一套强大的音视频处理工具,支持:

  • 音视频格式转换
  • 音视频剪辑与拼接
  • 提取音频/视频流
  • 添加水印/字幕
  • 调整音量/速度
  • 录音/录屏

官网:https://ffmpeg.org


2. 安装 FFmpeg

macOS

brew install ffmpeg

Linux (Ubuntu/Debian)

sudo apt update
sudo apt install ffmpeg

Windows

下载:https://ffmpeg.org/download.html

验证安装

ffmpeg -version

3. 常用命令

3.1 查看音视频信息

ffmpeg -i input.mp3

3.2 格式转换

# MP3 转 AAC
ffmpeg -i input.mp3 output.aac

# WAV 转 MP3 (指定比特率)
ffmpeg -i input.wav -b:a 192k output.mp3

3.3 音频剪辑

# 从 00:01:30 开始,截取 30 秒
ffmpeg -ss 00:01:30 -i input.mp3 -t 30 -c copy output.mp3

# 从 90 秒开始,截取到 180 秒
ffmpeg -ss 90 -i input.mp3 -to 180 -c copy output.mp3

3.4 音频拼接

# 方法1: 使用 concat filter (推荐)
ffmpeg -i input1.mp3 -i input2.mp3 -i input3.mp3 \
  -filter_complex "concat=n=3:v=0:a=1[out]" -map "[out]" output.mp3

# 方法2: 使用 concat demuxer (需要文件列表)
# filelist.txt 内容:
# file '/path/to/input1.mp3'
# file '/path/to/input2.mp3'
ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp3

3.5 音频混合

# 将两个音频混合在一起
ffmpeg -i background.mp3 -i voice.mp3 \
  -filter_complex "[0:a][1:a]amix=inputs=2:duration=longest" output.mp3

3.6 调整音量

# 提高音量到 2 倍
ffmpeg -i input.mp3 -filter:a "volume=2.0" output.mp3

# 降低音量到 50%
ffmpeg -i input.mp3 -filter:a "volume=0.5" output.mp3

3.7 提取视频中的音频

ffmpeg -i video.mp4 -vn -acodec copy output.aac

4. Java 集成方式

4.1 核心原理

Java 通过 ProcessBuilder 调用 FFmpeg 命令行:

ProcessBuilder pb = new ProcessBuilder(
    "ffmpeg",
    "-i", "input.mp3",
    "output.wav"
);
Process process = pb.start();
int exitCode = process.waitFor();

4.2 封装工具类

package cn.radio.iov.api.util.ffmpeg;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * FFmpeg 工具类
 */
public class FfmpegUtil {

    private final String ffmpegPath;

    public FfmpegUtil(String ffmpegPath) {
        this.ffmpegPath = ffmpegPath;
    }

    public FfmpegUtil() {
        // 默认从系统路径查找
        this.ffmpegPath = "ffmpeg";
    }

    /**
     * 获取音频信息(时长、采样率、比特率等)
     */
    public AudioInfo getAudioInfo(Path audioPath) throws IOException, InterruptedException {
        List<String> command = List.of(
            ffmpegPath,
            "-i", audioPath.toString(),
            "-f", "null",
            "-"
        );

        ProcessResult result = execute(command, true);

        // 解析输出获取信息
        return AudioInfo.parse(result.getErrorOutput());
    }

    /**
     * 音频格式转换
     */
    public void convertAudio(Path input, Path output, String format) throws IOException, InterruptedException {
        List<String> command = List.of(
            ffmpegPath,
            "-y",
            "-i", input.toString(),
            output.toString()
        );

        ProcessResult result = execute(command, false);

        if (result.getExitCode() != 0) {
            throw new RuntimeException("音频转换失败: " + result.getErrorOutput());
        }
    }

    /**
     * 音频剪辑
     */
    public void clipAudio(Path input, Path output, double startSeconds, double duration) throws IOException, InterruptedException {
        List<String> command = List.of(
            ffmpegPath,
            "-y",
            "-ss", String.valueOf(startSeconds),
            "-i", input.toString(),
            "-t", String.valueOf(duration),
            "-c", "copy",
            output.toString()
        );

        ProcessResult result = execute(command, false);

        if (result.getExitCode() != 0) {
            throw new RuntimeException("音频剪辑失败: " + result.getErrorOutput());
        }
    }

    /**
     * 执行 FFmpeg 命令
     */
    private ProcessResult execute(List<String> command, boolean suppressOutput) throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder(command);

        if (!suppressOutput) {
            pb.inheritIO();
        }

        Process process = pb.start();

        // 读取错误输出(FFmpeg 信息输出到 stderr)
        StringBuilder errorOutput = new StringBuilder();
        try (var reader = new BufferedReader(
                new InputStreamReader(process.getErrorStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                errorOutput.append(line).append("\n");
            }
        }

        int exitCode = process.waitFor();

        return new ProcessResult(exitCode, errorOutput.toString());
    }

    /**
     * 进程执行结果
     */
    private static class ProcessResult {
        private final int exitCode;
        private final String errorOutput;

        public ProcessResult(int exitCode, String errorOutput) {
            this.exitCode = exitCode;
            this.errorOutput = errorOutput;
        }

        public int getExitCode() {
            return exitCode;
        }

        public String getErrorOutput() {
            return errorOutput;
        }
    }

    /**
     * 音频信息
     */
    public static class AudioInfo {
        private double duration;
        private int sampleRate;
        private int bitrate;
        private String codec;

        public static AudioInfo parse(String output) {
            AudioInfo info = new AudioInfo();

            // 解析时长: Duration: 00:03:45.23
            java.util.regex.Pattern durationPattern =
                java.util.regex.Pattern.compile("Duration: (\\d+):(\\d+):(\\d+)\\.(\\d+)");
            java.util.regex.Matcher matcher = durationPattern.matcher(output);
            if (matcher.find()) {
                int hours = Integer.parseInt(matcher.group(1));
                int minutes = Integer.parseInt(matcher.group(2));
                int seconds = Integer.parseInt(matcher.group(3));
                int millis = Integer.parseInt(matcher.group(4));
                info.duration = hours * 3600 + minutes * 60 + seconds + millis / 1000.0;
            }

            // 解析采样率: 44100 Hz
            java.util.regex.Pattern samplePattern =
                java.util.regex.Pattern.compile("(\\d+) Hz");
            java.util.regex.Matcher sampleMatcher = samplePattern.matcher(output);
            if (sampleMatcher.find()) {
                info.sampleRate = Integer.parseInt(sampleMatcher.group(1));
            }

            return info;
        }

        public double getDuration() {
            return duration;
        }

        public int getSampleRate() {
            return sampleRate;
        }
    }
}

5. 实战案例

5.1 音频拆分器

将一个音频按时间段拆分成多个片段:

package cn.radio.iov.api.util.ffmpeg;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

/**
 * 音频拆分器
 */
public class AudioSplitter {

    private final String ffmpegPath;

    public AudioSplitter(String ffmpegPath) {
        this.ffmpegPath = ffmpegPath;
    }

    /**
     * 拆分音频
     * @param inputAudio 输入音频文件
     * @param outputDir 输出目录
     * @param clips 片段列表,每个片段包含 startTime, endTime
     */
    public void split(Path inputAudio, Path outputDir, List<AudioClip> clips)
            throws IOException, InterruptedException {

        for (AudioClip clip : clips) {
            String outputFile = String.format(
                "clip_%02d_%s.mp3",
                clip.getOrder(),
                System.currentTimeMillis()
            );

            // 计算时长
            double duration = clip.getEndTime() - clip.getStartTime();

            ProcessBuilder pb = new ProcessBuilder(
                ffmpegPath,
                "-y",                           // 覆盖输出文件
                "-ss", String.valueOf(clip.getStartTime()),  // 开始时间
                "-i", inputAudio.toString(),
                "-t", String.valueOf(duration),  // 持续时长
                "-c", "copy",                   // 复制编码,不重新编码
                outputDir.resolve(outputFile).toString()
            );

            pb.inheritIO();
            Process p = pb.start();
            int exitCode = p.waitFor();

            if (exitCode != 0) {
                throw new RuntimeException("拆分失败: " + clip.getOrder());
            }
        }
    }

    /**
     * 音频片段
     */
    public static class AudioClip {
        private int order;
        private double startTime;  // 秒
        private double endTime;    // 秒
        private String type;

        public int getOrder() { return order; }
        public double getStartTime() { return startTime; }
        public double getEndTime() { return endTime; }
        public String getType() { return type; }

        // builder 模式
        public static class Builder {
            private final AudioClip clip = new AudioClip();

            public Builder order(int order) { clip.order = order; return this; }
            public Builder startTime(double startTime) { clip.startTime = startTime; return this; }
            public Builder endTime(double endTime) { clip.endTime = endTime; return this; }
            public Builder type(String type) { clip.type = type; return this; }

            public AudioClip build() { return clip; }
        }
    }
}

5.2 音频拼接器

将多个音频拼接成一个:

package cn.radio.iov.api.util.ffmpeg;

import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;

/**
 * 音频拼接器
 */
public class AudioMerger {

    private final String ffmpegPath;

    public AudioMerger(String ffmpegPath) {
        this.ffmpegPath = ffmpegPath;
    }

    /**
     * 拼接多个音频
     * @param audioUrls 音频地址列表,支持 HTTP(S) URL 或本地路径
     * @param outputDir 输出目录
     * @return 拼接后的音频文件路径
     */
    public Path merge(List<String> audioUrls, Path outputDir)
            throws IOException, InterruptedException {

        Files.createDirectories(outputDir);

        // 创建临时目录
        Path tempDir = Files.createTempDirectory("audio_merge_");

        try {
            List<Path> localPaths = new ArrayList<>();

            // 下载/复制所有音频到临时目录
            for (int i = 0; i < audioUrls.size(); i++) {
                String url = audioUrls.get(i);
                Path tempFile = tempDir.resolve("input_" + i + ".mp3");

                if (url.startsWith("http")) {
                    try (var in = new URL(url).openStream()) {
                        Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING);
                    }
                } else {
                    Files.copy(Path.of(url), tempFile, StandardCopyOption.REPLACE_EXISTING);
                }
                localPaths.add(tempFile);
            }

            // 使用 ffmpeg concat filter 拼接
            Path outputFile = outputDir.resolve("merged_" + System.currentTimeMillis() + ".mp3");
            mergeWithFfmpeg(localPaths, outputFile);

            return outputFile;

        } finally {
            // 清理临时目录
            deleteDirectory(tempDir);
        }
    }

    private void mergeWithFfmpeg(List<Path> inputs, Path output)
            throws IOException, InterruptedException {

        List<String> command = new ArrayList<>();
        command.add(ffmpegPath);
        command.add("-y");

        // 添加所有输入文件
        for (Path input : inputs) {
            command.add("-i");
            command.add(input.toString());
        }

        // concat filter: 拼接 n 个音频,输出 1 个音频流
        String filter = "concat=n=" + inputs.size() + ":v=0:a=1[out]";
        command.add("-filter_complex");
        command.add(filter);
        command.add("-map");
        command.add("[out]");
        command.add(output.toString());

        ProcessBuilder pb = new ProcessBuilder(command);
        pb.inheritIO();
        Process p = pb.start();

        if (p.waitFor() != 0) {
            throw new RuntimeException("音频拼接失败");
        }
    }

    private void deleteDirectory(Path dir) throws IOException {
        if (!Files.exists(dir)) return;
        var files = dir.toFile().listFiles();
        if (files != null) {
            for (var f : files) {
                if (f.isDirectory()) {
                    deleteDirectory(f.toPath());
                } else {
                    Files.delete(f.toPath());
                }
            }
        }
        Files.delete(dir);
    }
}

5.3 使用示例

public class FfmpegDemo {

    public static void main(String[] args) throws Exception {
        String ffmpegPath = "/opt/homebrew/bin/ffmpeg";

        // 1. 音频拆分
        AudioSplitter splitter = new AudioSplitter(ffmpegPath);

        List<AudioSplitter.AudioClip> clips = List.of(
            new AudioSplitter.AudioClip.Builder()
                .order(1).startTime(0).endTime(30).type("intro").build(),
            new AudioSplitter.AudioClip.Builder()
                .order(2).startTime(30).endTime(60).type("content").build()
        );

        splitter.split(
            Path.of("/path/to/input.mp3"),
            Path.of("/path/to/output"),
            clips
        );

        // 2. 音频拼接
        AudioMerger merger = new AudioMerger(ffmpegPath);

        Path merged = merger.merge(
            List.of(
                "https://example.com/audio1.mp3",
                "/path/to/audio2.mp3"
            ),
            Path.of("/path/to/output")
        );

        System.out.println("合并完成: " + merged);
    }
}

6. 常见问题

Q1: FFmpeg 输出到 stderr?

A: FFmpeg 默认将日志输出到 stderr,正常输出到 stdout。

Q2: -ss 参数放在 -i 前还是后?

A:

  • 放在 -i 前:快速定位(可能不精确)
  • 放在 -i 后:精确定位(但会先解码前面的内容)

Q3: -c copy 和重新编码的区别?

A: -c copy 是复制流,速度快不损失质量,但某些操作(如拼接)需要重新编码。

Q4: concat filter vs concat demuxer?

A:

  • concat filter: 重新编码,兼容性好
  • concat demuxer: 使用 -c copy 快速拼接,要求文件格式/编码完全一致

7. 参考资料

  • FFmpeg 官方文档: https://ffmpeg.org/documentation.html
  • FFmpeg Wiki: https://trac.ffmpeg.org/wiki
  • concat filter 文档: https://ffmpeg.org/ffmpeg-filters.html#concat
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐