SpringBoot整合FFmpeg,打造你的专属视频处理工厂

大家好,我是小悟。

第一部分:认识 FFmpeg ------ 视频界的瑞士军刀

FFmpeg 是什么?想象一下,如果你有一个朋友,他能:

  • 把 MP4 变成 AVI,就像把咖啡变成奶茶
  • 裁剪视频,比理发师剪头发还精准
  • 提取音频,比从披萨上分离芝士还干净
  • 压缩视频,比你把行李箱塞满时还高效

这个"万能朋友"就是 FFmpeg!它是一个开源的声音/影像处理工具,功能强大到能让好莱坞特效师失业(开玩笑的)。

shell 复制代码
# FFmpeg 的基本心态:
# "给我一个视频,我能还你一个世界"
# 实际上它想说的是:"ffmpeg -i input.mp4 [一堆参数] output.mp4"

第二部分:整合步骤 ------ 像组装乐高一样简单

步骤1:先给项目来点"开胃菜"------ Maven依赖

xml 复制代码
<!-- pom.xml -->
<dependencies>
    <!-- SpringBoot 标准配置 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- 让我们记录FFmpeg的"精彩表演" -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>
    
    <!-- 视频处理时的"后悔药"------异常处理 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

步骤2:配置FFmpeg ------ 像教AI用筷子

kotlin 复制代码
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "ffmpeg")
@Data
public class FFmpegConfig {
    /**
     * FFmpeg可执行文件路径
     * Windows: "C:/ffmpeg/bin/ffmpeg.exe"
     * Linux/Mac: "/usr/bin/ffmpeg"
     */
    private String path;
    
    /**
     * 超时时间(秒)
     * 防止视频处理变成"永恒等待"
     */
    private Long timeout = 3600L;
    
    /**
     * 线程数
     * 多线程就像多双手,干活更快!
     */
    private Integer threads = 4;
}
yaml 复制代码
# application.yml
ffmpeg:
  path: /usr/local/bin/ffmpeg  # 你的FFmpeg安装路径
  timeout: 3600                # 1小时,足够看一集电视剧了
  threads: 4                   # 4个线程,四核处理器的最爱

步骤3:创建FFmpeg指挥官

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
public class FFmpegCommander {
    
    @Autowired
    private FFmpegConfig ffmpegConfig;
    
    /**
     * 执行FFmpeg命令
     * @param commands 命令参数(像给厨师递菜单)
     * @return 是否成功(厨子有没有把菜做糊)
     */
    public boolean execute(List<String> commands) {
        List<String> fullCommand = new ArrayList<>();
        fullCommand.add(ffmpegConfig.getPath());
        fullCommand.addAll(commands);
        
        log.info("FFmpeg开始干活啦!命令:{}", String.join(" ", fullCommand));
        
        ProcessBuilder processBuilder = new ProcessBuilder(fullCommand);
        processBuilder.redirectErrorStream(true); // 错误输出也给我看看
        
        try {
            Process process = processBuilder.start();
            
            // 读取输出,防止FFmpeg"自言自语"没人听
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    log.debug("FFmpeg悄悄说:{}", line);
                }
            }
            
            // 等待处理完成,别急着催
            int exitCode = process.waitFor();
            boolean success = exitCode == 0;
            
            if (success) {
                log.info("FFmpeg完美收工!");
            } else {
                log.error("FFmpeg罢工了!退出码:{}", exitCode);
            }
            
            return success;
            
        } catch (Exception e) {
            log.error("FFmpeg崩溃了,原因:{}", e.getMessage(), e);
            return false;
        }
    }
    
    /**
     * 获取FFmpeg版本(验明正身)
     */
    public String getVersion() {
        try {
            Process process = new ProcessBuilder(ffmpegConfig.getPath(), "-version").start();
            BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()));
            return reader.readLine(); // 第一行就是版本信息
        } catch (Exception e) {
            return "FFmpeg可能去度假了:" + e.getMessage();
        }
    }
}

步骤4:创建视频处理服务 ------ 你的私人视频管家

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class VideoService {
    
    @Autowired
    private FFmpegCommander ffmpegCommander;
    
    // 临时文件存放目录(像快递的临时存放点)
    private final String TEMP_DIR = System.getProperty("java.io.tmpdir") + "/video-process/";
    
    public VideoService() {
        // 确保临时目录存在
        new File(TEMP_DIR).mkdirs();
    }
    
    /**
     * 转换视频格式(像把中文翻译成英文)
     * @param inputFile 输入文件
     * @param targetFormat 目标格式(mp4, avi, mov...)
     */
    public File convertFormat(MultipartFile inputFile, String targetFormat) throws IOException {
        log.info("开始格式转换:{} → {}", 
                 getFileExtension(inputFile.getOriginalFilename()), 
                 targetFormat);
        
        // 1. 保存上传的文件(像把食材先放到厨房)
        File input = saveTempFile(inputFile);
        
        // 2. 准备输出文件(准备好盘子)
        String outputFileName = UUID.randomUUID() + "." + targetFormat;
        File output = new File(TEMP_DIR + outputFileName);
        
        // 3. 构建FFmpeg命令菜单
        List<String> commands = Arrays.asList(
            "-i", input.getAbsolutePath(),     // 输入文件
            "-threads", "4",                   // 用4个线程
            "-preset", "fast",                 // 快速预设
            "-c:v", "libx264",                 // 视频编码
            "-c:a", "aac",                     // 音频编码
            "-y",                              // 覆盖输出文件(别问我是否确定)
            output.getAbsolutePath()           // 输出文件
        );
        
        // 4. 让FFmpeg大厨开始烹饪
        boolean success = ffmpegCommander.execute(commands);
        
        // 5. 清理临时文件(洗盘子)
        input.delete();
        
        if (success && output.exists()) {
            log.info("格式转换成功!文件大小:{} MB", 
                     output.length() / (1024 * 1024));
            return output;
        } else {
            throw new RuntimeException("转换失败,FFmpeg可能去做美甲了");
        }
    }
    
    /**
     * 提取视频缩略图(给视频拍证件照)
     */
    public File extractThumbnail(MultipartFile videoFile, int second) throws IOException {
        log.info("正在给视频拍第{}秒的证件照...", second);
        
        File input = saveTempFile(videoFile);
        String outputFileName = UUID.randomUUID() + ".jpg";
        File output = new File(TEMP_DIR + outputFileName);
        
        List<String> commands = Arrays.asList(
            "-i", input.getAbsolutePath(),
            "-ss", String.valueOf(second),    // 跳转到指定秒数
            "-vframes", "1",                  // 只要1帧
            "-vf", "scale=320:-1",           // 缩放到宽度320,高度自动
            "-y",
            output.getAbsolutePath()
        );
        
        boolean success = ffmpegCommander.execute(commands);
        input.delete();
        
        if (success && output.exists()) {
            log.info("缩略图生成成功!");
            return output;
        }
        throw new RuntimeException("拍照失败,视频可能害羞了");
    }
    
    /**
     * 压缩视频(给视频减肥)
     */
    public File compressVideo(MultipartFile videoFile, int targetBitrate) throws IOException {
        log.info("开始给视频减肥,目标比特率:{}k", targetBitrate);
        
        File input = saveTempFile(videoFile);
        long originalSize = input.length();
        
        String outputFileName = UUID.randomUUID() + "_compressed.mp4";
        File output = new File(TEMP_DIR + outputFileName);
        
        List<String> commands = Arrays.asList(
            "-i", input.getAbsolutePath(),
            "-threads", "4",
            "-b:v", targetBitrate + "k",      // 目标视频比特率
            "-b:a", "128k",                   // 音频比特率
            "-y",
            output.getAbsolutePath()
        );
        
        boolean success = ffmpegCommander.execute(commands);
        input.delete();
        
        if (success && output.exists()) {
            long compressedSize = output.length();
            double ratio = (1.0 - (double)compressedSize/originalSize) * 100;
            log.info("减肥成功!原大小:{}MB,现大小:{}MB,瘦身:{:.1f}%",
                     originalSize/(1024*1024),
                     compressedSize/(1024*1024),
                     ratio);
            return output;
        }
        throw new RuntimeException("减肥失败,视频可能偷吃宵夜了");
    }
    
    /**
     * 合并视频和音频(像给电影配音)
     */
    public File mergeVideoAudio(MultipartFile videoFile, 
                                MultipartFile audioFile) throws IOException {
        log.info("开始给视频配音...");
        
        File video = saveTempFile(videoFile);
        File audio = saveTempFile(audioFile);
        
        String outputFileName = UUID.randomUUID() + "_merged.mp4";
        File output = new File(TEMP_DIR + outputFileName);
        
        List<String> commands = Arrays.asList(
            "-i", video.getAbsolutePath(),
            "-i", audio.getAbsolutePath(),
            "-c:v", "copy",                   // 视频流直接复制(不重新编码)
            "-c:a", "aac",                    // 音频重新编码
            "-map", "0:v:0",                  // 取第一个文件的视频
            "-map", "1:a:0",                  // 取第二个文件的音频
            "-shortest",                      // 以最短的流为准
            "-y",
            output.getAbsolutePath()
        );
        
        boolean success = ffmpegCommander.execute(commands);
        video.delete();
        audio.delete();
        
        if (success && output.exists()) {
            log.info("配音成功!新视频诞生了");
            return output;
        }
        throw new RuntimeException("合并失败,可能视频和音频在闹离婚");
    }
    
    private File saveTempFile(MultipartFile file) throws IOException {
        String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
        Path path = Paths.get(TEMP_DIR + fileName);
        Files.copy(file.getInputStream(), path);
        return path.toFile();
    }
    
    private String getFileExtension(String filename) {
        if (filename == null) return "unknown";
        int dotIndex = filename.lastIndexOf('.');
        return (dotIndex == -1) ? "" : filename.substring(dotIndex + 1);
    }
}

步骤5:创建控制器 ------ 视频处理的接待处

less 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;

@Slf4j
@RestController
@RequestMapping("/api/video")
public class VideoController {
    
    @Autowired
    private VideoService videoService;
    
    @Autowired
    private FFmpegCommander ffmpegCommander;
    
    @GetMapping("/version")
    public String getFFmpegVersion() {
        String version = ffmpegCommander.getVersion();
        return "{\"version\": \"" + version + "\"}";
    }
    
    @PostMapping("/convert")
    public ResponseEntity<Resource> convertFormat(
            @RequestParam("file") MultipartFile file,
            @RequestParam("format") String format,
            HttpServletResponse response) throws IOException {
        
        log.info("收到转换请求:{} → {}", file.getOriginalFilename(), format);
        
        File converted = videoService.convertFormat(file, format);
        
        return buildFileResponse(converted, 
                "converted." + format, 
                MediaType.APPLICATION_OCTET_STREAM);
    }
    
    @PostMapping("/thumbnail")
    public ResponseEntity<Resource> extractThumbnail(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "second", defaultValue = "5") int second) throws IOException {
        
        File thumbnail = videoService.extractThumbnail(file, second);
        
        return buildFileResponse(thumbnail,
                "thumbnail.jpg",
                MediaType.IMAGE_JPEG);
    }
    
    @PostMapping("/compress")
    public ResponseEntity<Resource> compressVideo(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "bitrate", defaultValue = "1000") int bitrate) throws IOException {
        
        File compressed = videoService.compressVideo(file, bitrate);
        
        return buildFileResponse(compressed,
                "compressed.mp4",
                MediaType.APPLICATION_OCTET_STREAM);
    }
    
    @PostMapping("/merge")
    public ResponseEntity<Resource> mergeVideoAudio(
            @RequestParam("video") MultipartFile video,
            @RequestParam("audio") MultipartFile audio) throws IOException {
        
        File merged = videoService.mergeVideoAudio(video, audio);
        
        return buildFileResponse(merged,
                "merged.mp4",
                MediaType.APPLICATION_OCTET_STREAM);
    }
    
    private ResponseEntity<Resource> buildFileResponse(File file, 
                                                       String filename,
                                                       MediaType mediaType) {
        if (!file.exists()) {
            return ResponseEntity.notFound().build();
        }
        
        Resource resource = new FileSystemResource(file);
        
        // 文件下载完成后自动删除(深藏功与名)
        file.deleteOnExit();
        
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, 
                        "attachment; filename=\"" + filename + "\"")
                .contentType(mediaType)
                .contentLength(file.length())
                .body(resource);
    }
}

步骤6:添加异常处理 ------ 给程序买份保险

typescript 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleException(Exception e) {
        log.error("系统闹情绪了:{}", e.getMessage(), e);
        
        Map<String, Object> response = new HashMap<>();
        response.put("success", false);
        response.put("message", "服务器开小差了,可能是FFmpeg在偷懒");
        response.put("error", e.getMessage());
        response.put("timestamp", System.currentTimeMillis());
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }
    
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<Map<String, Object>> handleMaxSizeException() {
        Map<String, Object> response = new HashMap<>();
        response.put("success", false);
        response.put("message", "文件太大了,服务器拿不动了");
        response.put("suggestion", "请尝试压缩视频或上传小一点的文件");
        
        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                .body(response);
    }
    
    @ExceptionHandler(IOException.class)
    public ResponseEntity<Map<String, Object>> handleIOException(IOException e) {
        Map<String, Object> response = new HashMap<>();
        response.put("success", false);
        response.put("message", "文件读写出了问题,可能是磁盘在闹脾气");
        response.put("error", e.getMessage());
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }
}

第三部分:使用示例 ------ 让我们来实际操练一下

1. 启动应用程序

typescript 复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class VideoProcessingApplication {
    public static void main(String[] args) {
        SpringApplication.run(VideoProcessingApplication.class, args);
        System.out.println("视频处理服务启动成功!");
        System.out.println("FFmpeg整装待发,随时准备处理你的视频");
    }
}

2. 测试API

使用Postman或curl测试:

bash 复制代码
# 查看FFmpeg版本
curl http://localhost:8080/api/video/version

# 转换视频格式
curl -X POST -F "file=@input.avi" -F "format=mp4" \
     http://localhost:8080/api/video/convert --output output.mp4

# 提取缩略图
curl -X POST -F "file=@video.mp4" -F "second=10" \
     http://localhost:8080/api/video/thumbnail --output thumbnail.jpg

# 压缩视频
curl -X POST -F "file=@large_video.mp4" -F "bitrate=500" \
     http://localhost:8080/api/video/compress --output compressed.mp4

第四部分:高级技巧 ------ 让FFmpeg更懂你

1. 添加进度监听(给视频处理加个进度条)

arduino 复制代码
public interface ProgressListener {
    void onProgress(double percentage, String message);
    void onComplete(File outputFile);
    void onError(String error);
}

// 在FFmpegCommander中添加进度解析
private void parseProgress(String line, ProgressListener listener) {
    // 解析FFmpeg的输出,提取进度信息
    // 示例输出:frame=  123 fps=25.1 time=00:00:04.92 bitrate= 512.0kbits/s
    if (line.contains("time=")) {
        // 这里可以解析时间,计算进度百分比
        // 实际实现需要根据视频总时长计算
    }
}

2. 批量处理(一次处理多个文件)

kotlin 复制代码
public List<File> batchConvert(List<MultipartFile> files, String format) {
    return files.parallelStream()  // 并行处理,更快!
            .map(file -> {
                try {
                    return videoService.convertFormat(file, format);
                } catch (IOException e) {
                    log.error("转换失败:{}", file.getOriginalFilename(), e);
                    return null;
                }
            })
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
}

3. 视频信息提取(给视频做体检)

typescript 复制代码
public Map<String, Object> getVideoInfo(File videoFile) {
    // 使用FFprobe(FFmpeg的小伙伴)获取视频信息
    List<String> commands = Arrays.asList(
        "-v", "error",
        "-select_streams", "v:0",
        "-show_entries", "stream=width,height,duration,bit_rate,codec_name",
        "-of", "json",
        videoFile.getAbsolutePath()
    );
    
    // 执行命令并解析JSON结果
    // 返回包含分辨率、时长、码率、编码格式等信息
}

第五部分:总结与注意事项

成功整合的秘诀:

  1. 正确安装FFmpeg:确保系统PATH中有FFmpeg,或者配置正确的路径

    bash 复制代码
    # 检查安装
    ffmpeg -version
  2. 资源管理

    • 视频处理很吃内存,记得给JVM足够的内存

      java -Xmx2g -jar your-application.jar

    • 及时清理临时文件,防止磁盘被撑爆

  3. 错误处理

    • FFmpeg可能会因为各种原因失败(不支持的格式、损坏的文件等)
    • 添加重试机制和详细的日志记录
  4. 安全性

    • 限制上传文件类型和大小
    • 对用户输入进行严格验证
    • 防止命令注入攻击

可能遇到的坑:

  1. 跨平台问题:Windows和Linux下的路径差异
  2. 编码问题:中文字符在命令中可能需要特殊处理
  3. 权限问题:确保应用有执行FFmpeg的权限
  4. 性能问题:大文件处理可能需要很长时间,考虑异步处理

为什么选择这个方案?

  1. 灵活性强:可以执行任何FFmpeg支持的操作
  2. 功能全面:视频处理界的"瑞士军刀"
  3. 社区支持好:遇到问题容易找到解决方案
  4. 免费开源:省钱又省心

最后:

FFmpeg就像一把强大的电锯------功能强大但需要小心使用。不要在生产环境直接运行未经验证的命令,否则可能会:

  1. 把服务器CPU烧得像烤红薯
  2. 让磁盘空间消失得比钱包里的钱还快
  3. 产生一堆让你怀疑人生的临时文件

但只要你按照本文的步骤,像对待一只温顺的猫一样对待FFmpeg,它就会成为你在视频处理领域最得力的助手!

祝你的视频处理之路顺畅无比,就像FFmpeg处理一个10秒的GIF一样轻松!

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

相关推荐
大傻^13 分钟前
LangChain4j Spring Boot Starter:自动配置与声明式 Bean 管理
java·人工智能·spring boot·spring·langchain4j
沐硕15 分钟前
《基于改进协同过滤与多目标优化的健康饮食推荐系统设计与实现》
java·python·算法·fastapi·多目标优化·饮食推荐·改进协同过滤
yhole21 分钟前
springboot 修复 Spring Framework 特定条件下目录遍历漏洞(CVE-2024-38819)
spring boot·后端·spring
BingoGo26 分钟前
Laravel 13 正式发布 使用 Laravel AI 无缝平滑升级
后端·php
愣头不青31 分钟前
560.和为k的子数组
java·数据结构
共享家952737 分钟前
Java入门(String类)
java·开发语言
l软件定制开发工作室42 分钟前
Spring开发系列教程(34)——打包Spring Boot应用
java·spring boot·后端·spring·springboot
0xDevNull44 分钟前
Spring Boot 循环依赖解决方案完全指南
java·开发语言·spring
爱丽_1 小时前
GC 怎么判定“该回收谁”:GC Roots、可达性分析、四种引用与回收算法
java·jvm·算法
bbq粉刷匠1 小时前
Java--多线程--单例模式
java·开发语言·单例模式