做后端开发难免碰到视频处理需求:格式转码、截缩略图、查视频参数……市面上轮子虽多,但FFmpeg依旧是最稳、兼容性最强的方案。这篇文章不讲虚的,纯实战带大家在 Spring Boot 3 里集成 FFmpeg,代码直接复制就能跑,踩过的坑也一并说明白。
一、先搞懂:FFmpeg 到底是什么?
很多同学刚接触会懵,其实不用深究底层编解码,记住三个核心工具就行:
-
ffmpeg:主力命令行工具,转码、剪辑、加水印全靠它;
-
ffprobe:专门读取视频元数据(时长、分辨率、编码格式),轻量高效;
-
ffplay:简易播放器,本地调试用得上,服务器环境一般不用装。
咱们的集成思路很直白:Spring Boot 调用系统命令,执行 FFmpeg 指令,不用引入复杂依赖,上手快、稳定性高。
二、环境前置要求
-
JDK 17+;
-
Spring Boot 3.0+(本文用 3.2.5);
-
FFmpeg 4.0 以上版本;
-
Windows/Linux/MacOS 均可,核心是装好 FFmpeg 并配置环境变量。
三、FFmpeg 安装+环境校验
这步最容易出错,装完一定要验证命令可用性,不然代码写半天跑不起来。
Windows 安装
-
去官网下载 FFmpeg 完整包,选 Windows Full Build 版本;
-
解压到固定目录(比如
D:\ffmpeg-6.1.1\bin),把这个路径加到系统Path环境变量; -
开 CMD 输入
ffmpeg -version,能出版本信息就算成功。
Linux快速安装
别源码编译,太费时间,直接用 yum 安装:
# 先装扩展源
yum install -y epel-release
# 直接安装
yum install -y ffmpeg
# 校验
ffmpeg -version
MacOS 安装
Homebrew 一键搞定:
brew install ffmpeg
ffmpeg -version
重点提醒:Linux 服务器部署时,一定要确认ffmpeg 命令全局可用,如果找不到命令,就写绝对路径(比如 /usr/bin/ffmpeg),后面工具类里改下常量就行。
四、Spring Boot 3 项目搭建
依赖引入
不用额外加 FFmpeg 依赖,只需要基础 Web 依赖, Lombok 可选:
<dependencies>
<!-- Web接口 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
配置文件(application.yml)
主要改文件上传大小,默认 1MB 根本不够传视频:
server:
port: 8080
spring:
servlet:
multipart:
# 单个文件最大限制
max-file-size: 100MB
# 请求总大小限制
max-request-size: 100MB
核心工具类封装
这是整个集成的核心,封装格式转换、截缩略图、读取元数据三个常用功能,解决命令阻塞、超时等坑。
代码里加了详细注释,异步读流是关键——不这么做会导致命令执行卡死。
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class FFmpegUtil {
// FFmpeg命令前缀,配置了环境变量直接写ffmpeg,Linux找不到就写绝对路径
private static final String FFMPEG_CMD = "ffmpeg";
/**
* 视频格式转换(无损复制流,速度超快)
* @param inputPath 原视频绝对路径
* @param outputPath 输出文件绝对路径
* @param timeout 超时时间(秒)
* @return 执行结果
*/
public boolean convertVideo(String inputPath, String outputPath, long timeout) {
// -c:v copy -c:a copy 直接复制音视频流,不重新编码,大文件也快
String[] cmd = {
FFMPEG_CMD,
"-i", inputPath,
"-c:v", "copy",
"-c:a", "copy",
"-y", // 覆盖已有文件,避免交互卡住
outputPath
};
return executeCmd(cmd, timeout);
}
/**
* 截取视频缩略图
* @param inputPath 原视频路径
* @param outputPath 截图保存路径(jpg/png均可)
* @param time 截取时间点(例:00:00:02 或 2)
* @return 执行结果
*/
public boolean generateThumb(String inputPath, String outputPath, String time) {
String[] cmd = {
FFMPEG_CMD,
"-i", inputPath,
"-ss", time,
"-vframes", "1", // 只截1帧
"-y",
outputPath
};
return executeCmd(cmd, 60);
}
/**
* 获取视频元数据(时长、分辨率、编码)
* @param inputPath 视频路径
* @return 元数据集合
*/
public Map<String, String> getVideoInfo(String inputPath) {
Map<String, String> infoMap = new HashMap<>();
// ffprobe 命令读取视频流信息
String[] cmd = {
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,duration,codec_name",
"-of", "default=noprint_wrappers=1:nokey=0",
inputPath
};
try {
Process process = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("width=")) infoMap.put("width", line.split("=")[1]);
if (line.startsWith("height=")) infoMap.put("height", line.split("=")[1]);
if (line.startsWith("duration=")) infoMap.put("duration", line.split("=")[1]);
if (line.startsWith("codec_name=")) infoMap.put("codec", line.split("=")[1]);
}
process.waitFor(30, TimeUnit.SECONDS);
reader.close();
} catch (Exception e) {
log.error("读取视频信息失败", e);
}
return infoMap;
}
/**
* 执行系统命令(解决阻塞、超时问题)
*/
private boolean executeCmd(String[] cmd, long timeout) {
Process process = null;
try {
log.info("执行FFmpeg命令:{}", String.join(" ", cmd));
process = Runtime.getRuntime().exec(cmd);
// 异步读取流,必须加!否则缓冲区满会卡死进程
readStreamAsync(process.getInputStream());
readStreamAsync(process.getErrorStream());
// 等待执行完成,超时强制销毁进程
boolean finish = process.waitFor(timeout, TimeUnit.SECONDS);
if (!finish) {
log.error("FFmpeg命令执行超时,强制终止");
process.destroyForcibly();
return false;
}
// 退出码0代表执行成功
return process.exitValue() == 0;
} catch (Exception e) {
log.error("FFmpeg命令执行异常", e);
return false;
} finally {
if (process != null) process.destroy();
}
}
/**
* 异步读取命令输出流(避免阻塞)
*/
private void readStreamAsync(java.io.InputStream in) {
new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
log.debug("FFmpeg输出:{}", line);
}
} catch (IOException e) {
log.error("读取流失败", e);
}
}).start();
}
}
接口开发
写三个简易接口,上传视频直接测试功能,临时文件存在项目根目录,生产环境记得换成云存储(OSS/MinIO)。
import com.example.ffmpeg.util.FFmpegUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/video")
@RequiredArgsConstructor
public class VideoController {
private final FFmpegUtil ffmpegUtil;
// 临时文件目录
private static final String TEMP_PATH = System.getProperty("user.dir") + "/temp/";
// 初始化目录
static {
File dir = new File(TEMP_PATH);
if (!dir.exists()) dir.mkdirs();
}
/**
* 视频格式转换接口
*/
@PostMapping("/convert")
public String convert(@RequestParam("file") MultipartFile file,
@RequestParam("targetFormat") String targetFormat) throws IOException {
// 保存上传文件
String originalName = file.getOriginalFilename();
String inputName = UUID.randomUUID() + originalName.substring(originalName.lastIndexOf("."));
String inputPath = TEMP_PATH + inputName;
file.transferTo(new File(inputPath));
// 输出文件
String outputName = UUID.randomUUID() + "." + targetFormat;
String outputPath = TEMP_PATH + outputName;
boolean result = ffmpegUtil.convertVideo(inputPath, outputPath, 120);
return result ? "转换成功,路径:" + outputPath : "转换失败";
}
/**
* 生成缩略图接口
*/
@PostMapping("/thumb")
public String thumb(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "00:00:01") String time) throws IOException {
String inputPath = TEMP_PATH + UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
file.transferTo(new File(inputPath));
String outputPath = TEMP_PATH + UUID.randomUUID() + ".jpg";
boolean result = ffmpegUtil.generateThumb(inputPath, outputPath, time);
return result ? "缩略图生成成功:" + outputPath : "生成失败";
}
/**
* 获取视频信息接口
*/
@PostMapping("/info")
public Map<String, String> info(@RequestParam("file") MultipartFile file) throws IOException {
String inputPath = TEMP_PATH + UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
file.transferTo(new File(inputPath));
return ffmpegUtil.getVideoInfo(inputPath);
}
}
五、实测验证与常见问题、调优指南
接口测试
-
截缩略图:POST
http://localhost:8080/api/video/thumb,form-data 传 file 和 time 参数; -
格式转换:POST
http://localhost:8080/api/video/convert,传 file 和 targetFormat(如 webm/flv); -
查视频信息:POST
http://localhost:8080/api/video/info,只传 file 即可。
常见问题
-
命令执行卡住:没加异步读流,复制工具类代码即可解决;
-
Linux 找不到 ffmpeg:把工具类里
FFMPEG_CMD改成绝对路径; -
文件上传失败:检查 yml 里文件大小配置,别设太小;
-
权限报错:Linux 给 temp 目录开读写权限,Windows 别放系统盘受限目录。
后端视频处理天花板!SpringBoot3+FFmpeg 极简集成方案
做后端开发难免碰到视频处理需求:格式转码、截缩略图、查视频参数……市面上轮子虽多,但FFmpeg依旧是最稳、兼容性最强的方案。这篇文章纯实战带大家在 Spring Boot 3 里集成 FFmpeg,代码直接复制就能跑。
一、先搞懂:FFmpeg 到底是什么?
很多同学刚接触会懵,其实不用深究底层编解码,记住三个核心工具就行:
-
ffmpeg:主力命令行工具,转码、剪辑、加水印全靠它;
-
ffprobe:专门读取视频元数据(时长、分辨率、编码格式),轻量高效;
-
ffplay:简易播放器,本地调试用得上,服务器环境一般不用装。
咱们的集成思路很直白:Spring Boot 调用系统命令,执行 FFmpeg 指令,不用引入复杂依赖,上手快、稳定性高。
二、环境前置要求
-
JDK 17+;
-
Spring Boot 3.0+(本文用 3.2.5);
-
FFmpeg 4.0 以上版本;
-
Windows/Linux/MacOS 均可,核心是装好 FFmpeg 并配置环境变量。
三、FFmpeg 安装+环境校验
这步最容易出错,装完一定要验证命令可用性,不然代码写半天跑不起来。
Windows 安装
-
去官网下载 FFmpeg 完整包,选 Windows Full Build 版本;
-
解压到固定目录(比如
D:\ffmpeg-6.1.1\bin),把这个路径加到系统Path环境变量; -
开 CMD 输入
ffmpeg -version,能出版本信息就算成功。
Linux快速安装
别源码编译,太费时间,直接用 yum 安装:
# 先装扩展源
yum install -y epel-release
# 直接安装
yum install -y ffmpeg
# 校验
ffmpeg -version
MacOS 安装
Homebrew 一键搞定:
brew install ffmpeg
ffmpeg -version
重点提醒:Linux 服务器部署时,一定要确认ffmpeg 命令全局可用,如果找不到命令,就写绝对路径(比如 /usr/bin/ffmpeg),后面工具类里改下常量就行。
四、Spring Boot 3 项目搭建
依赖引入
不用额外加 FFmpeg 依赖,只需要基础 Web 依赖, Lombok 可选:
<dependencies>
<!-- Web接口 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
配置文件(application.yml)
主要改文件上传大小,默认 1MB 根本不够传视频:
server:
port: 8080
spring:
servlet:
multipart:
# 单个文件最大限制
max-file-size: 100MB
# 请求总大小限制
max-request-size: 100MB
核心工具类封装
这是整个集成的核心,封装格式转换、截缩略图、读取元数据三个常用功能,解决命令阻塞、超时等坑。
代码里加了详细注释,异步读流是关键——不这么做会导致命令执行卡死。
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class FFmpegUtil {
// FFmpeg命令前缀,配置了环境变量直接写ffmpeg,Linux找不到就写绝对路径
private static final String FFMPEG_CMD = "ffmpeg";
/**
* 视频格式转换(无损复制流,速度超快)
* @param inputPath 原视频绝对路径
* @param outputPath 输出文件绝对路径
* @param timeout 超时时间(秒)
* @return 执行结果
*/
public boolean convertVideo(String inputPath, String outputPath, long timeout) {
// -c:v copy -c:a copy 直接复制音视频流,不重新编码,大文件也快
String[] cmd = {
FFMPEG_CMD,
"-i", inputPath,
"-c:v", "copy",
"-c:a", "copy",
"-y", // 覆盖已有文件,避免交互卡住
outputPath
};
return executeCmd(cmd, timeout);
}
/**
* 截取视频缩略图
* @param inputPath 原视频路径
* @param outputPath 截图保存路径(jpg/png均可)
* @param time 截取时间点(例:00:00:02 或 2)
* @return 执行结果
*/
public boolean generateThumb(String inputPath, String outputPath, String time) {
String[] cmd = {
FFMPEG_CMD,
"-i", inputPath,
"-ss", time,
"-vframes", "1", // 只截1帧
"-y",
outputPath
};
return executeCmd(cmd, 60);
}
/**
* 获取视频元数据(时长、分辨率、编码)
* @param inputPath 视频路径
* @return 元数据集合
*/
public Map<String, String> getVideoInfo(String inputPath) {
Map<String, String> infoMap = new HashMap<>();
// ffprobe 命令读取视频流信息
String[] cmd = {
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,duration,codec_name",
"-of", "default=noprint_wrappers=1:nokey=0",
inputPath
};
try {
Process process = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("width=")) infoMap.put("width", line.split("=")[1]);
if (line.startsWith("height=")) infoMap.put("height", line.split("=")[1]);
if (line.startsWith("duration=")) infoMap.put("duration", line.split("=")[1]);
if (line.startsWith("codec_name=")) infoMap.put("codec", line.split("=")[1]);
}
process.waitFor(30, TimeUnit.SECONDS);
reader.close();
} catch (Exception e) {
log.error("读取视频信息失败", e);
}
return infoMap;
}
/**
* 执行系统命令(解决阻塞、超时问题)
*/
private boolean executeCmd(String[] cmd, long timeout) {
Process process = null;
try {
log.info("执行FFmpeg命令:{}", String.join(" ", cmd));
process = Runtime.getRuntime().exec(cmd);
// 异步读取流,必须加!否则缓冲区满会卡死进程
readStreamAsync(process.getInputStream());
readStreamAsync(process.getErrorStream());
// 等待执行完成,超时强制销毁进程
boolean finish = process.waitFor(timeout, TimeUnit.SECONDS);
if (!finish) {
log.error("FFmpeg命令执行超时,强制终止");
process.destroyForcibly();
return false;
}
// 退出码0代表执行成功
return process.exitValue() == 0;
} catch (Exception e) {
log.error("FFmpeg命令执行异常", e);
return false;
} finally {
if (process != null) process.destroy();
}
}
/**
* 异步读取命令输出流(避免阻塞)
*/
private void readStreamAsync(java.io.InputStream in) {
new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line;
while ((line = reader.readLine()) != null) {
log.debug("FFmpeg输出:{}", line);
}
} catch (IOException e) {
log.error("读取流失败", e);
}
}).start();
}
}
接口开发
写三个简易接口,上传视频直接测试功能,临时文件存在项目根目录,生产环境记得换成云存储(OSS/MinIO)。
import com.example.ffmpeg.util.FFmpegUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/video")
@RequiredArgsConstructor
public class VideoController {
private final FFmpegUtil ffmpegUtil;
// 临时文件目录
private static final String TEMP_PATH = System.getProperty("user.dir") + "/temp/";
// 初始化目录
static {
File dir = new File(TEMP_PATH);
if (!dir.exists()) dir.mkdirs();
}
/**
* 视频格式转换接口
*/
@PostMapping("/convert")
public String convert(@RequestParam("file") MultipartFile file,
@RequestParam("targetFormat") String targetFormat) throws IOException {
// 保存上传文件
String originalName = file.getOriginalFilename();
String inputName = UUID.randomUUID() + originalName.substring(originalName.lastIndexOf("."));
String inputPath = TEMP_PATH + inputName;
file.transferTo(new File(inputPath));
// 输出文件
String outputName = UUID.randomUUID() + "." + targetFormat;
String outputPath = TEMP_PATH + outputName;
boolean result = ffmpegUtil.convertVideo(inputPath, outputPath, 120);
return result ? "转换成功,路径:" + outputPath : "转换失败";
}
/**
* 生成缩略图接口
*/
@PostMapping("/thumb")
public String thumb(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "00:00:01") String time) throws IOException {
String inputPath = TEMP_PATH + UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
file.transferTo(new File(inputPath));
String outputPath = TEMP_PATH + UUID.randomUUID() + ".jpg";
boolean result = ffmpegUtil.generateThumb(inputPath, outputPath, time);
return result ? "缩略图生成成功:" + outputPath : "生成失败";
}
/**
* 获取视频信息接口
*/
@PostMapping("/info")
public Map<String, String> info(@RequestParam("file") MultipartFile file) throws IOException {
String inputPath = TEMP_PATH + UUID.randomUUID() + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
file.transferTo(new File(inputPath));
return ffmpegUtil.getVideoInfo(inputPath);
}
}
五、实测验证与常见问题、调优指南
接口测试
-
截缩略图:POST
http://localhost:8080/api/video/thumb,form-data 传 file 和 time 参数; -
格式转换:POST
http://localhost:8080/api/video/convert,传 file 和 targetFormat(如 webm/flv); -
查视频信息:POST
http://localhost:8080/api/video/info,只传 file 即可。
常见问题
-
命令执行卡住:没加异步读流,复制工具类代码即可解决;
-
Linux 找不到 ffmpeg:把工具类里
FFMPEG_CMD改成绝对路径; -
文件上传失败:检查 yml 里文件大小配置,别设太小;
-
权限报错:Linux 给 temp 目录开读写权限,Windows 别放系统盘受限目录。
生产环境优化
上面是本地实战代码,上线要改这几点,不然容易出问题:
-
异步化处理:视频转码耗时长,用
@Async或消息队列(RabbitMQ/Kafka)异步执行,别同步接口硬等; -
换掉本地存储:本地临时文件分布式环境访问不到,换成阿里云 OSS、MinIO 等云存储;
-
参数校验:限制上传文件格式、大小,防止命令注入和恶意文件;
-
控制并发:FFmpeg 耗CPU,用线程池限制同时执行的任务数;
-
日志+重试:记录任务状态,失败自动重试,方便排查问题。
六、总结
Spring Boot 集成 FFmpeg 没那么复杂,核心就是命令调用+流处理,不用引入第三方封装依赖,少一层依赖就少一层隐患。
本文代码都是实测可用的,扩展功能(加水印、剪辑、抽音频)也很简单,只需要改 FFmpeg 命令参数就行,官方文档查命令语法,直接套进工具类即可。
(注:文档部分内容可能由 AI 生成)
生产环境优化
上面是本地实战代码,上线要改这几点,不然容易出问题:
-
异步化处理:视频转码耗时长,用
@Async或消息队列(RabbitMQ/Kafka)异步执行,别同步接口硬等; -
换掉本地存储:本地临时文件分布式环境访问不到,换成阿里云 OSS、MinIO 等云存储;
-
参数校验:限制上传文件格式、大小,防止命令注入和恶意文件;
-
控制并发:FFmpeg 耗CPU,用线程池限制同时执行的任务数;
-
日志+重试:记录任务状态,失败自动重试,方便排查问题。
六、总结
Spring Boot 集成 FFmpeg 没那么复杂,核心就是命令调用+流处理,不用引入第三方封装依赖,少一层依赖就少一层隐患。
本文代码都是实测可用的,扩展功能(加水印、剪辑、抽音频)也很简单,只需要改 FFmpeg 命令参数就行,官方文档查命令语法,直接套进工具类即可。