后端视频处理天花板!SpringBoot3+FFmpeg 极简集成方案

0 阅读13分钟

做后端开发难免碰到视频处理需求:格式转码、截缩略图、查视频参数……市面上轮子虽多,但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 安装

  1. 去官网下载 FFmpeg 完整包,选 Windows Full Build 版本;

  2. 解压到固定目录(比如 D:\ffmpeg-6.1.1\bin),把这个路径加到系统 Path 环境变量;

  3. 开 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 参数;

  • 格式转换:POSThttp://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 安装

  1. 去官网下载 FFmpeg 完整包,选 Windows Full Build 版本;

  2. 解压到固定目录(比如 D:\ffmpeg-6.1.1\bin),把这个路径加到系统 Path 环境变量;

  3. 开 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 参数;

  • 格式转换:POSThttp://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 别放系统盘受限目录。

生产环境优化

上面是本地实战代码,上线要改这几点,不然容易出问题:

  1. 异步化处理:视频转码耗时长,用 @Async 或消息队列(RabbitMQ/Kafka)异步执行,别同步接口硬等;

  2. 换掉本地存储:本地临时文件分布式环境访问不到,换成阿里云 OSS、MinIO 等云存储;

  3. 参数校验:限制上传文件格式、大小,防止命令注入和恶意文件;

  4. 控制并发:FFmpeg 耗CPU,用线程池限制同时执行的任务数;

  5. 日志+重试:记录任务状态,失败自动重试,方便排查问题。

六、总结

Spring Boot 集成 FFmpeg 没那么复杂,核心就是命令调用+流处理,不用引入第三方封装依赖,少一层依赖就少一层隐患。

本文代码都是实测可用的,扩展功能(加水印、剪辑、抽音频)也很简单,只需要改 FFmpeg 命令参数就行,官方文档查命令语法,直接套进工具类即可。

(注:文档部分内容可能由 AI 生成)

生产环境优化

上面是本地实战代码,上线要改这几点,不然容易出问题:

  1. 异步化处理:视频转码耗时长,用 @Async 或消息队列(RabbitMQ/Kafka)异步执行,别同步接口硬等;

  2. 换掉本地存储:本地临时文件分布式环境访问不到,换成阿里云 OSS、MinIO 等云存储;

  3. 参数校验:限制上传文件格式、大小,防止命令注入和恶意文件;

  4. 控制并发:FFmpeg 耗CPU,用线程池限制同时执行的任务数;

  5. 日志+重试:记录任务状态,失败自动重试,方便排查问题。

六、总结

Spring Boot 集成 FFmpeg 没那么复杂,核心就是命令调用+流处理,不用引入第三方封装依赖,少一层依赖就少一层隐患。

本文代码都是实测可用的,扩展功能(加水印、剪辑、抽音频)也很简单,只需要改 FFmpeg 命令参数就行,官方文档查命令语法,直接套进工具类即可。