☰
Java调用FFmpeg实战:从ProcessBuilder到进度监控完整指南
2026/10/14 4:00:45 网站建设 项目流程

接手过不少 Java 项目,但凡跟视频沾点边,最后几乎都会绕到同一个问题上:怎么让 Java 程序调用 FFmpeg。这篇文章就是解决这个问题的。我会从最底层的 ProcessBuilder 讲起,把命令怎么写、日志怎么读、进度怎么算、跨平台怎么处理这些坑一个一个说清楚。无论你是刚接触视频处理的初学者,还是已经在用 Runtime.exec() 硬扛的老手,这篇文章都适合你。

1. 先想清楚:你要调的是命令行 FFmpeg,还是 FFmpeg 的库?

很多人在网上搜"Java 调用 FFmpeg",搜出来的第一类结果是 javacv。这个方向不能说错,但大概率不是你要的。

javacv 做的事情,是把 FFmpeg 的核心库用 JNI 包了一层,然后通过 Java 方法直接调用 libavcodec、libavformat 这些底层的 C 接口。走这条路,意味着你的代码会跟 FFmpeg 的某个具体版本深度绑定,升级版本可能要重新适配,跨平台要操心不同操作系统的动态库问题,而且很多底层行为是通过 Java 对象间接控制的,出了问题排查起来非常痛苦。

我们项目组早先在一个模拟项目里试过 javacv,最直观的感受是:本来一条 ffmpeg 命令行就能解决的转码需求,用 javacv 写出来要几十行 Java 代码,还要理解 FrameGrabber、FFmpegFrameRecorder 这些非常规概念,学习成本完全不划算。

所以我的建议非常明确:如果只是把 FFmpeg 当工具用——转格式、抽帧、裁剪、拼接、加滤镜——那就老老实实走外部进程路线,用 ProcessBuilder 去执行 ffmpeg 可执行文件。这不是技术上的妥协,而是工程上的最优解。

对比维度外部进程方式javacv/JNI 方式
集成难度低,只需找到 ffmpeg 可执行文件高,需要理解 JNI 封装和底层 API
FFmpeg 版本升级直接替换二进制文件即可需要升级 javacv 版本并重新适配
功能覆盖范围完整覆盖所有 FFmpeg 能力取决于 javacv 封装的完整度
资源清理进程结束即释放,无内存泄漏风险需要手动释放 FrameGrabber 等对象
通用性任何语言都能用同一种思路仅限 Java 生态
部署体积只需携带 ffmpeg 可执行文件需要随包携带多个平台的动态库

一句话总结:除非你要在 Java 里做实时帧级别的视频处理(比如直播推流中对每一帧做 AI 识别),否则完全没必要上 JNI 方案。命令行 FFmpeg 能干的事情,比大多数人想象的多得多。

2. ProcessBuilder 才是正路:Runtime.exec() 的三个经典坑

确定了外部进程路线,接下来就是用哪个 API 执行命令的问题。JDK 里有两个选择:Runtime.exec() 和 ProcessBuilder。现在再写新代码,强烈建议只用 ProcessBuilder。

先用一段代码演示最基本的调用长什么样:

String ffmpegPath = "/usr/local/bin/ffmpeg"; String inputFile = "/data/videos/source.avi"; String outputFile = "/data/videos/output.mp4"; ProcessBuilder pb = new ProcessBuilder( ffmpegPath, "-y", "-i", inputFile, "-c:v", "libx264", "-c:a", "aac", outputFile ); pb.redirectErrorStream(true); Process process = pb.start(); int exitCode = process.waitFor(); if (exitCode == 0) { System.out.println("转码成功"); } else { System.out.println("转码失败,退出码:" + exitCode); }

这段代码能跑通,但只是最基础的形态。如果就这么拿去生产环境,大概率要出事。下面说说 Runtime.exec() 和单纯用 ProcessBuilder 时最容易踩的三个坑。

第一个坑:命令必须按数组传,别拼字符串。

Runtime.exec() 有一个重载是接收 String 的,很多人图省事就写:

Runtime.getRuntime().exec("ffmpeg -y -i input.avi output.mp4");

看起来没问题,但如果 ffmpeg 的路径里带空格,或者输入文件路径带空格,这段代码就废了。因为 exec() 把整个字符串直接丢给底层 shell 解析,空格被当成参数分隔符。哪怕你用 ProcessBuilder,如果偷懒用pb.command("ffmpeg -i xx")传一个带空格的字符串,同样会踩这个坑。

正确做法是始终用数组或可变参数形式,让 ProcessBuilder 自己处理参数转义:

ProcessBuilder pb = new ProcessBuilder( "/usr/local/bin/ffmpeg", "-y", "-i", "/data/my videos/source.avi", "-c:v", "libx264", "/data/my videos/output.mp4" );

第二个坑:错误输出会悄悄堵死管道。

ProcessBuilder 创建的子进程有三个标准流:stdin、stdout、stderr。默认情况下,stdout 和 stderr 各自对应一个管道缓冲区,Java 进程需要主动去读。要是你不去读,缓冲区满了之后,FFmpeg 就会阻塞在写日志的操作上,整个转码卡死。

FFmpeg 的日志输出量非常大,转码一个半小时的电影,stderr 日志能写几千行。所以单纯调 waitFor() 等着进程结束,极大概率会死锁。解决方式有很多,最简单的是pb.redirectErrorStream(true),把 stderr 合并到 stdout,这样只需要一个线程去消费合并后的流。

第三个坑:waitFor() 的阻塞陷阱与超时控制。

process.waitFor()是无条件阻塞的,FFmpeg 转码一个超大文件可能要几十分钟甚至几个小时。如果转码过程中系统出了问题,或者 FFmpeg 因为某个奇怪的原因挂住了,你的 Java 线程会永远卡在那里。

JDK 8 之后提供了带超时参数的 waitFor:

if (!process.waitFor(30, TimeUnit.MINUTES)) { process.destroyForcibly(); throw new RuntimeException("转码超时,已强制终止"); }

超过 30 分钟还没结束就强制干掉,保障主流程不会被拖死。生产环境的任务调度,这种超时保护是必须加的。

3. 写一个可复用的 FFmpegTask:线程、日志、返回码一起管

真实项目中不会像上面那样零散地调用,建议封装一个任务类,把进程启动、日志消费、超时控制、返回码处理全部收敛在一起。

我的做法是定义一个 FFmpegTask,构造函数接受 ffmpeg 路径、输入文件、输出文件,以及可选的额外参数列表。execute() 方法负责完整执行流程,返回处理结果。

先看完整代码,后面逐段解释:

import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; public class FFmpegTask { private final String ffmpegPath; private final Path inputFile; private final Path outputFile; private final List<String> extraArgs; private final long timeout; private final TimeUnit timeoutUnit; public FFmpegTask(String ffmpegPath, Path inputFile, Path outputFile, List<String> extraArgs, long timeout, TimeUnit timeoutUnit) { this.ffmpegPath = ffmpegPath; this.inputFile = inputFile; this.outputFile = outputFile; this.extraArgs = extraArgs == null ? new ArrayList<>() : extraArgs; this.timeout = timeout; this.timeoutUnit = timeoutUnit; } public FFmpegResult execute() throws IOException, InterruptedException { List<String> command = buildCommand(); System.out.println("执行命令:" + String.join(" ", command)); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); StringBuilder logBuilder = new StringBuilder(); Thread logThread = new Thread(() -> consumeLog(process, logBuilder)); logThread.setDaemon(true); logThread.start(); boolean finished = process.waitFor(timeout, timeoutUnit); if (!finished) { process.destroyForcibly(); logThread.join(2000); return FFmpegResult.timeout(logBuilder.toString()); } logThread.join(2000); return FFmpegResult.of(process.exitValue(), logBuilder.toString()); } private List<String> buildCommand() { List<String> cmd = new ArrayList<>(); cmd.add(ffmpegPath); cmd.add("-y"); cmd.add("-i"); cmd.add(inputFile.toString()); cmd.addAll(extraArgs); cmd.add(outputFile.toString()); return cmd; } private void consumeLog(Process process, StringBuilder logBuilder) { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), Charset.forName("UTF-8")))) { String line; while ((line = reader.readLine()) != null) { logBuilder.append(line).append('\n'); } } catch (IOException ignored) { // 进程结束时会触发流关闭,正常情况不必处理 } } }

几个设计上的细节值得说一下。

extraArgs 用 List 而不是 String,是为了在构建命令时保持参数完整性。比如要加-vf scale=1280:720,如果写成"-vf scale=1280:720",这个带空格的整个字符串会被当成一个参数传给 FFmpeg,FFmpeg 会直接报错。正确的写法是把-vf和scale=1280:720分开:

List<String> args = Arrays.asList("-c:v", "libx264", "-vf", "scale=1280:720"); FFmpegTask task = new FFmpegTask(ffmpegPath, input, output, args, 30, TimeUnit.MINUTES);

日志消费放在单独的线程里,这是必须的。只要 Java 进程持续读取输出流,FFmpeg 的日志就不会堵住管道。这里用 StringBuilder 接收日志,好处是整个任务的内存占用可控,不会像 File 方式那样产生临时文件。如果需要完整日志归档,可以换成写到文件。

返回结果封装在一个 FFmpegResult 对象里:

public class FFmpegResult { public enum Status { SUCCESS, FAILED, TIMEOUT } private final Status status; private final int exitCode; private final String log; private FFmpegResult(Status status, int exitCode, String log) { this.status = status; this.exitCode = exitCode; this.log = log; } public static FFmpegResult of(int exitCode, String log) { Status status = exitCode == 0 ? Status.SUCCESS : Status.FAILED; return new FFmpegResult(status, exitCode, log); } public static FFmpegResult timeout(String log) { return new FFmpegResult(Status.TIMEOUT, -1, log); } public boolean isSuccess() { return status == Status.SUCCESS; } public int getExitCode() { return exitCode; } public String getLog() { return log; } public Status getStatus() { return status; } }

有了这个封装,业务层调用变得非常干净:

FFmpegResult result = new FFmpegTask( config.getFfmpegPath(), sourcePath, targetPath, transcodeArgs, 30, TimeUnit.MINUTES ).execute(); if (result.isSuccess()) { // 处理成功,后续流程 } else { // 记录 result.getLog() 里的失败原因 log.error("转码失败,退出码:{},日志:{}", result.getExitCode(), result.getLog()); }

这里补充一个排查技巧:FFmpeg 退出码非 0 时,一定要看日志。大多数时候日志最后几行会直接给出失败原因,比如Invalid argument、Permission denied、No such file or directory,比到处猜高效得多。

4. stderr 是 FFmpeg 的主场:日志消费的三种姿势

前面提到 FFmpeg 的日志默认输出到 stderr,这里展开讲讲为什么强调这一点,顺便介绍三种日志消费方式的取舍。

先明确一个事实:FFmpeg 把普通的 info 级别日志、进度条、警告和错误信息全部写到 stderr,而 stdout 几乎全程空着。这种设计源于 Unix 的惯例——stdout 留给真正的数据输出,比如ffmpeg -i input.mp4 somefile.mp3时如果需要输出二进制数据流到 stdout,stderr 依然承担日志职责。

如果只是在 Java 里调用 FFmpeg,最简单的方式是pb.redirectErrorStream(true),把 stderr 合并成一条管道。这样做的好处是只需要消费一个流,坏处是无法区分哪些输出是正常日志、哪些是错误。对于一个纯转码任务来说,这个区分意义不大——反正最终都是看退出码,退出码为 0 就是成功,日志只是辅助信息。

第二种姿势是把日志直接重定向到文件:

ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); pb.redirectOutput(new File("/logs/ffmpeg/20240513_153000.log")); Process process = pb.start();

这种方式的好处非常明显:

  • Java 进程完全不需要关心日志内容,不存在缓冲区阻塞问题
  • 日志留档,出问题可以事后复盘
  • 对内存零压力

缺点是日志文件需要自行管理,包括命名策略、轮转、保留周期。

第三种姿势是 StreamGobbler 模式,开两个线程分别消费 stdout 和 stderr。只有在确实需要区分标准输出和错误输出的时候才值得用。比如用 FFmpeg 输出原始数据流,同时还要捕获错误日志的场景。

Thread errGobbler = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getErrorStream()))) { String line; while ((line = reader.readLine()) != null) { errorLog.append(line).append('\n'); } } catch (IOException e) { Thread.currentThread().interrupt(); } }); errGobbler.setDaemon(true); errGobbler.start();

我个人的推荐是:开发调试阶段用第一种,方便在控制台看到完整输出;生产环境用第二种,尤其是转码量大、日志多的场景,把日志落盘比在内存里堆字符串靠谱得多。第三种姿势能用,但是双线程的代码复杂度更高,收益却不明显,非必须不选。

还有一个容易忽略的点:FFmpeg 的 stderr 默认会输出 ANSI 转义序列来刷新进度条,如果直接消费进日志,里面会混进一堆[A这种控制字符。想要日志干净,在执行命令时加一个-nostats参数,禁用默认的统计输出。配合-v error可以只保留错误级别的日志,排查问题时信息密度反而更高。

5. 进度百分比:用 -progress 协议而不是去啃控制台输出

转码任务一跑就是几分钟甚至更久,用户界面上不能只有一个 spinner,得告诉用户"转码到百分之多少了"。这时候才会意识到,前面那些日志消费方案解决的是"不阻塞"的问题,但要想拿到精确的进度,还得靠 FFmpeg 的 -progress 协议。

第一反应可能是解析 stderr 里的进度条,那个每秒刷新的日志看起来确实包含进度信息。但仔细看就会发现它输出的是frame= 1234 fps= 56.2 q=28.0 size= 1024kB time=00:01:23.45 bitrate=...这种格式,而且带有 ANSI 转义字符,解析起来非常痛苦。

正确的做法是使用-progress pipe:1参数,让 FFmpeg 把机器可读的进度信息写到 stdout:

List<String> args = Arrays.asList( "-c:v", "libx264", "-progress", "pipe:1", "-nostats" );

加上这个参数后,stdout 会每隔一小段时间输出一组 key=value 格式的数据:

frame=526 fps=98.3 stream_0_0_q=28.0 bitrate=523.4kbits/s total_size=1342177 out_time_us=52345678 out_time_ms=52345678 out_time=00:00:52.345678 dup_frames=0 drop_frames=0 speed=1.83x progress=continue

注意最后一行的progress=end,这是 FFmpeg 明确告诉你转码结束的信号。

要算百分比,第一步是拿到总时长。用 ffprobe 探测输入文件的时长:

ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4

输出是一个字符串,比如125.234000,单位是秒。

第二步,从 stdout 里解析 out_time_us(微秒)。每读到一组 key=value,就把 out_time_us 提取出来:

private long parseOutTimeUs(String keyValueBlock) { for (String line : keyValueBlock.split("\n")) { if (line.startsWith("out_time_us=")) { String value = line.substring("out_time_us=".length()).trim(); return Long.parseLong(value); } } return -1; }

第三步,换算成百分比:

double durationSec = probeDurationSeconds(inputFile); double progressSec = outTimeUs / 1_000_000.0; int percent = Math.min(99, (int) (progressSec / durationSec * 100));

这里有个细节:百分比上限设为 99,因为最后一个点必须等 FFmpeg 退出码为 0 才代表 100% 完成。否则转码失败但进度到了 100%,界面就会显示一个假完成。

整个进度解析可以做一个独立的 ProgressReader,一边消费 stdout,一边把计算好的 percent 通过回调推送给前端:

public class FFmpegProgressReader implements Runnable { private final InputStream inputStream; private final double durationSec; private final Consumer<Integer> progressCallback; public FFmpegProgressReader(InputStream inputStream, double durationSec, Consumer<Integer> progressCallback) { this.inputStream = inputStream; this.durationSec = durationSec; this.progressCallback = progressCallback; } @Override public void run() { StringBuilder block = new StringBuilder(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(inputStream, Charset.forName("UTF-8")))) { String line; while ((line = reader.readLine()) != null) { if (line.equals("progress=end")) { break; } block.append(line).append('\n'); if (line.startsWith("progress=")) { long outTimeUs = parseOutTimeUs(block.toString()); if (outTimeUs >= 0 && durationSec > 0) { double progressSec = outTimeUs / 1_000_000.0; int percent = (int) (progressSec / durationSec * 100); progressCallback.accept(Math.min(99, percent)); } block.setLength(0); } } } catch (IOException e) { Thread.currentThread().interrupt(); } } }

这样设计的好处是:业务层只需要调用一个带回调的 execute 方法,就能实时拿到进度。FFmpeg 的底层协议完全被封装在任务内部。

6. 上线前必须处理的跨平台与资源限制

最后一个话题,也是生产环境最容易翻车的部分。开发环境一切正常,测试环境也跑得通,一上生产就各种诡异问题。先看一个典型的例子:

开发同学用的 Mac,测试服务器是 CentOS,生产环境是 Windows Server。代码里写的是ffmpeg这个命令,Mac 上因为有 Homebrew 安装的 FFmpeg,PATH 里能找得到;测试服务器上运维手动编译了 FFmpeg,放在/usr/local/bin/,没问题;生产 Windows 上没人装过 FFmpeg,直接报Cannot run program "ffmpeg": CreateProcess error=2。

跨平台的第一个原则:Ffmpeg 路径永不硬编码。配置文件里放一个ffmpeg.path配置项,环境不同就配不同的值:

# 开发环境 ffmpeg.path=/usr/local/bin/ffmpeg
# 生产环境 ffmpeg.path=/opt/ffmpeg/bin/ffmpeg.exe

读取配置的逻辑放在启动阶段,并且启动时立刻做一次健康检查,用ffmpeg -version验证可执行文件是否可用,不行就直接 fail fast:

ProcessBuilder checkPb = new ProcessBuilder(ffmpegPath, "-version"); Process checkProcess = checkPb.start(); boolean exitOk = checkProcess.waitFor(5, TimeUnit.SECONDS); if (!exitOk || checkProcess.exitValue() != 0) { throw new IllegalStateException("FFmpeg 不可用:" + ffmpegPath); }

第二个原则:Linux 上要确认可执行权限。很多团队把 ffmpeg 可执行文件打进部署包,结果部署到服务器上忘了chmod +x,运行时直接 permission denied。发布脚本里加上:

chmod +x /opt/ffmpeg/bin/ffmpeg

第三个原则:控制 CPU 和内存资源。FFmpeg 默认会按 CPU 核心数尽可能多地启用线程,一个转码任务就能把整台服务器压满。在一个 16 核的机器上如果同时跑两三个转码任务,负载直接爆掉,其他业务请求全部变慢。

合理的做法是限制线程数和码率相关参数:

List<String> args = Arrays.asList( "-threads", "2", "-c:v", "libx264", "-preset", "veryfast", "-b:v", "2000k", "-c:a", "aac", "-b:a", "128k" );

-threads 2限制编码线程数,-preset veryfast让 x264 用更快的预设换取 CPU 占用下降,虽然体积会稍大,但对于内部系统的视频压缩足够用了。追求更精细的资源控制,可以配一个线程池来管理并发转码任务,限制同时运行的 FFmpegTask 数量,比如 4 个:

ExecutorService executor = Executors.newFixedThreadPool(4); Future<FFmpegResult> future = executor.submit(new FFmpegTask(...));

第四个原则:尽量选择静态编译的 FFmpeg 二进制。动态编译版本依赖系统里的共享库,比如 libx264.so、libmp3lame.so,目标机器缺一个就跑不起来。静态编译版本把所有依赖打进二进制内部,分发时只需要一个文件。生产环境我一般固定用一个静态编译版本做全量验证,验证过了就不再随意升级版本,把不确定性降到最低。

还有容器部署的情况:Docker 镜像里可以直接安装 FFmpeg,或者在做镜像时把静态二进制 COPY 进去。重点不是选哪种方式,而是保证运行环境的行为一致。压测阶段就要在生产同配置的容器里跑一遍转码,别等到上线了才发现容器里的运行差别。

写在实际操作之后

最后分享一个我在正式环境实际踩过的坑。有一个转码任务,输入是用户上传的一个 1080p 的录屏文件,FFmpeg 参数里忘了加-threads限制。这台机器上同时有数据库服务和几个 Web 应用,结果转码一开始,CPU 直接飙到 100%,数据库响应时间瞬间从 5ms 涨到 300ms,整个接口链路雪崩,最后靠手动杀掉转码进程才恢复。从那之后,团队定了一条规矩:所有调用 FFmpeg 的任务,必须显式指定-threads,哪怕就是写-threads 4,也不能留默认值。默认的"自动"往往不是你要的最优解,在生产环境尤其如此。

另一个小技巧:写 FFmpeg 命令行参数时,输入参数放在输出参数之前,而且-i后面的输入文件紧跟-i。这是 FFmpeg 的固定语法,参数顺序错了进程会直接退出。调试的时候如果 FFmpeg 报Option ... not found或者Unable to find a suitable output format,先别怀疑代码,看看是不是参数顺序写错了,八成就是这个问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询