springboot调用ffmpeg实现对视频的截图,截取与水印

1.视频处理服务

java 复制代码
package com.caige.openai.service.impl;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.multipart.MultipartFile;

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.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;


@Component
public class VideoProcessor {

    private static final Logger log = LoggerFactory.getLogger(VideoProcessor.class);

    /**
     * 根据不同系统创建空路径
     */
    private static final File DEV_NULL =
            System.getProperty("os.name", "").toLowerCase().startsWith("win")
                    ? new File("NUL")
                    : new File("/dev/null");

    public Path saveToTempFile(MultipartFile file) throws IOException {
        String ext = getFileExtension(file.getOriginalFilename());
        Path tempFile = Files.createTempFile("upload_", "." + ext);
        Files.write(tempFile, file.getBytes());
        log.debug("文件临时路径==={}", tempFile.toAbsolutePath());
        return tempFile;
    }

    // 获取文件扩展名
    private String getFileExtension(String filename) {
        if (StrUtil.isBlank(filename)) {
            return "tmp";
        }

        int dotIndex = filename.lastIndexOf('.');
        return (dotIndex == -1) ? "tmp" : filename.substring(dotIndex + 1).toLowerCase();
    }

    /**
     * 执行ffmpeg命令
     *
     * @param command 指令集合
     * @throws IOException
     * @throws InterruptedException
     */
    private void runFfmpeg(List<String> command) throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder(command);
        // 合并 stderr 和 stdout
        pb.redirectErrorStream(true);
        pb.redirectInput(DEV_NULL);
        Process process = pb.start();

        int exitCode = process.waitFor();
        if (exitCode != 0) {
            log.error("ffmpeg命令执行失败,退出码==={}", exitCode);
            throw new RuntimeException("FFmpeg 命令执行失败,退出码: " + exitCode);
        }
    }

    /**
     * 获取视频分辨率,返回 [width, height]
     */
    public static int[] getVideoResolution(Path videoPath) throws IOException, InterruptedException {
        List<String> command = Arrays.asList(
                "ffprobe",
                "-v", "error",
                "-select_streams", "v:0",
                "-show_entries", "stream=width,height",
                "-of", "csv=s=,:p=0",
                videoPath.toAbsolutePath().toString()
        );

        ProcessBuilder pb = new ProcessBuilder(command);
        Process process = pb.start();

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line = reader.readLine();
            if (line == null) {
                throw new RuntimeException("无法读取视频分辨率");
            }

            // 示例输出: "1920,1080"
            String[] wh = line.split(",");
            if (wh.length != 2) {
                throw new RuntimeException("解析分辨率失败: " + line);
            }

            int width = Integer.parseInt(wh[0].trim());
            int height = Integer.parseInt(wh[1].trim());
            return new int[]{width, height};
        } finally {
            process.waitFor();
        }
    }

    public double getVideoDurationSeconds(Path videoPath) throws IOException, InterruptedException {
        List<String> command = new ArrayList<>(8);
        command.add("ffprobe");
        command.add("-v");
        command.add("error"); // 只输出错误,抑制其他日志
        command.add("-show_entries");
        command.add("format=duration");
        command.add("-of");
        command.add("default=nw=1"); // 输出纯数字,如 62.345
        command.add(videoPath.toAbsolutePath().toString());

        ProcessBuilder pb = new ProcessBuilder(command);
        Process process = pb.start();

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
            String line = reader.readLine();
            if (StrUtil.isBlank(line)) {
                throw new RuntimeException("无法获取视频时长");
            }
            // 解析 "duration=32.949002" → 提取 "32.949002"
            String trimmed = line.trim();
            if (trimmed.startsWith("duration=")) {
                return Double.parseDouble(trimmed.substring("duration=".length()));
            } else {
                // 兜底:尝试直接解析(兼容未来格式变化)
                return Double.parseDouble(trimmed);
            }
        } finally {
            process.waitFor();
        }
    }
    
    private List<Integer> calcSnapshotTimes(double totalSeconds, int intervalSeconds) {
        if (intervalSeconds <= 0) {
            intervalSeconds = 1;
        }
        int finalIntervalSeconds = intervalSeconds;
        return IntStream.iterate(0, t -> t <= totalSeconds, t -> t + finalIntervalSeconds)
                        .boxed()
                        .collect(Collectors.toList());
    }

    private static Path creatTmpOutputPath(String name) {
        return Paths.get(System.getProperty("java.io.tmpdir"), name);
    }

    public List<Path> takeSnapshot(Path videoPath, int second) throws IOException, InterruptedException {
        log.debug("当前操作系统==={}", System.getProperty("os.name", "").toLowerCase());
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("视频截图完成耗时");

        double duration = getVideoDurationSeconds(videoPath);
        String videoAbsPath = videoPath.toAbsolutePath().toString();
        log.debug("视频总时长: {} 秒,截图间隔: {} 秒", duration, second);

        List<Integer> times = calcSnapshotTimes(duration, second);
        log.debug("将截图时间点: {}", times);

        List<Path> paths = new ArrayList<>();

        for (int i = 0; i < times.size(); i++) {
            Integer time = times.get(i);
            String name = "snapshot_" + time + "_" + i + ".jpg";
            Path outputPath = creatTmpOutputPath(name);
            String outPath = outputPath.toAbsolutePath().toString();

            List<String> cmd = new ArrayList<>(11);
            cmd.add("ffmpeg");
            cmd.add("-ss");
            cmd.add(String.valueOf(time));
            cmd.add("-i");
            cmd.add(videoAbsPath);
            cmd.add("-vframes");
            cmd.add("1");
            cmd.add("-q:v");
            cmd.add("2");
            cmd.add(outPath);
            cmd.add("-y");
            runFfmpeg(cmd);

            paths.add(outputPath);
        }
        stopWatch.stop();
        long millis = stopWatch.getTotalTimeMillis();
        String seconds = String.format("%.2f", millis / 1000.0);
        log.debug("{}==={}ms,{}s,数量==={}", stopWatch.lastTaskInfo().getTaskName(), millis, seconds, paths.size());
        return paths;
    }

    public Path addWatermark(Path videoPath, Path watermarkPath) throws IOException, InterruptedException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("视频添加水印完成耗时");

        // 1. 获取视频分辨率
        int[] resolution = getVideoResolution(videoPath);
        int videoWidth = resolution[0];

        // 2. 计算水印目标宽度(例如占视频宽度的 15%), 最小 100px,避免太小看不清, 最大 400px,防止超大屏下水印过大
        int wmTargetWidth = Math.max(100, (int) (videoWidth * 0.15));
        wmTargetWidth = Math.min(wmTargetWidth, 400);

        // 3. 构建 filter:先缩放水印,再叠加到右上角
        String filterComplex = String.format(
                "[1:v]scale=%d:-1[wm];[0:v][wm]overlay=main_w-overlay_w-10:10",
                wmTargetWidth
        );

        String outputFileName = "watermarked_" + UUID.randomUUID() + ".mp4";
        Path outputPath = creatTmpOutputPath(outputFileName);
        String outPath = outputPath.toAbsolutePath().toString();

        List<String> command = new ArrayList<>(13);
        command.add("ffmpeg");
        command.add("-i");
        command.add(videoPath.toAbsolutePath().toString());
        command.add("-i");
        command.add(watermarkPath.toAbsolutePath().toString());
        command.add("-filter_complex");
        command.add(filterComplex); // 右上角,距右10px,距上10px
        command.add("-c:a");
        command.add("aac"); // 确保音频兼容(某些格式需重编码)
        command.add("-strict");
        command.add("-2");
        command.add(outPath);
        command.add("-y");

        runFfmpeg(command);

        stopWatch.stop();
        long millis = stopWatch.getTotalTimeMillis();
        String seconds = String.format("%.2f", millis / 1000.0);
        log.debug("{}==={}ms,{}s,带水印视频路径==={}", stopWatch.lastTaskInfo().getTaskName(), millis, seconds, outPath);

        return outputPath;
    }

    public Path cutVideo(Path videoPath, int startSecond, int duration) throws IOException, InterruptedException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("视频剪辑完成耗时");

        String outputFileName = "cut_" + UUID.randomUUID() + ".mp4";
        Path outputPath = creatTmpOutputPath(outputFileName);
        String outPath = outputPath.toAbsolutePath().toString();
        // 计算视频总时长
        double durationSeconds = getVideoDurationSeconds(videoPath);
        log.debug("当前视频总时长==={}s,截取视频时长==={}s", durationSeconds, duration - startSecond);

        List<String> command = new ArrayList<>(17);
        command.add("ffmpeg");
        command.add("-ss");
        command.add(String.valueOf(startSecond));
        command.add("-i");
        command.add(videoPath.toAbsolutePath().toString());
        command.add("-t");
        command.add(duration >= durationSeconds ? String.valueOf(durationSeconds) : String.valueOf(duration));
        command.add("-c:v");
        command.add("libx264");
        command.add("-c:a");
        command.add("aac");
        command.add("-strict");
        command.add("-2");
        command.add("-preset");
        command.add("fast"); // 编码速度 vs 质量
        command.add(outPath);
        command.add("-y");

        runFfmpeg(command);

        stopWatch.stop();
        long millis = stopWatch.getTotalTimeMillis();
        String seconds = String.format("%.2f", millis / 1000.0);
        log.debug("{}==={}ms,{}s,文件路径==={}", stopWatch.lastTaskInfo().getTaskName(), millis, seconds, outPath);
        return outputPath;
    }

    public void buildZip(Path zipPath, List<Path> snapshotPaths) {
        if (CollectionUtil.isEmpty(snapshotPaths)) {
            return;
        }
        try {
            try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
                for (Path img : snapshotPaths) {
                    ZipEntry entry = new ZipEntry(img.getFileName().toString());
                    zos.putNextEntry(entry);
                    Files.copy(img, zos);
                    zos.closeEntry();
                }
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

2.视频处理控制器

java 复制代码
package com.caige.openai.contoller;


import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import com.caige.openai.service.impl.VideoProcessor;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@RestController
@RequestMapping("/video")
public class VideoController {

    private static final Logger log = LoggerFactory.getLogger(VideoController.class);

    @Resource
    VideoProcessor videoProcessor;

    /**
     * 视频生成帧截图
     *
     * @param video   视频文件
     * @param second  间隔秒数
     * @return {@link ResponseEntity<FileSystemResource>}
     */
    @PostMapping(value = "/snapshot", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<FileSystemResource> snapshot(
            @RequestParam("video") MultipartFile video,
            @RequestParam(name = "second", defaultValue = "5") int second) {
        log.debug("获取截图参数=={}, 时间=={}", video, second);
        List<Path> pathList = new ArrayList<>();
        try {
            Path videoPath = videoProcessor.saveToTempFile(video);
            pathList = videoProcessor.takeSnapshot(videoPath, second);

            Path zipPath = Files.createTempFile("auto_snapshots_", ".zip");
            log.debug("生成zip路径==={}", zipPath.toAbsolutePath());
            videoProcessor.buildZip(zipPath, pathList);

            pathList.add(videoPath);
            pathList.add(zipPath);

            return buildSafeDisposition(zipPath);
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 视频添加水印
     *
     * @param video     视频文件
     * @param watermark 图片文件,必须是png格式
     * @return {@link ResponseEntity<FileSystemResource>}
     */
    @PostMapping(value = "/watermark", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<FileSystemResource> addWatermark(
            @RequestParam("video") MultipartFile video,
            @RequestParam("watermark") MultipartFile watermark) {
        List<Path> pathList = new ArrayList<>();
        try {

            if (ObjectUtil.hasNull(video, watermark)) {
                throw new RuntimeException("视频文件与水印png图片不能为空");
            }

            String extName = FileUtil.extName(watermark.getOriginalFilename());
            if (!StrUtil.equalsIgnoreCase(extName, "png")) {
                throw new RuntimeException("水印图片必须为PNG格式");
            }

            // 通过 MIME 类型验证是否真的是 PNG 图片
            if (!StrUtil.equalsIgnoreCase(watermark.getContentType(), "image/png")) {
                throw new RuntimeException("上传的文件MIME类型不匹配,必须是image/png");
            }

            Path videoPath = videoProcessor.saveToTempFile(video);
            Path wmPath = videoProcessor.saveToTempFile(watermark);
            Path resultPath = videoProcessor.addWatermark(videoPath, wmPath);

            pathList.add(videoPath);
            pathList.add(wmPath);
            pathList.add(resultPath);

            return buildSafeDisposition(resultPath);
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 视频截取
     *
     * @param video    视频文件
     * @param start    开始时间------秒
     * @param duration 截止时间------秒
     * @return {@link ResponseEntity<FileSystemResource>}
     */
    @PostMapping(value = "/cut", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<FileSystemResource> cutVideo(
            @RequestParam("video") MultipartFile video,
            @RequestParam("start") int start,
            @RequestParam("duration") int duration) {
        if (duration < 5) {
            throw new IllegalArgumentException("剪辑时长必须大于5秒");
        }

        Path videoPath = null;
        Path resultPath = null;
        try {
            videoPath = videoProcessor.saveToTempFile(video);
            resultPath = videoProcessor.cutVideo(videoPath, start, duration);
            return buildSafeDisposition(resultPath);
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    private ResponseEntity<FileSystemResource> buildSafeDisposition(Path path) throws IOException {
        String encodedName = URLUtil.encode(String.valueOf(path.getFileName()), StandardCharsets.UTF_8);
        String disposition = String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
                                           encodedName,
                                           encodedName);
        FileSystemResource resource = new FileSystemResource(path.toFile());
        return ResponseEntity.ok()
                             .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
                             .header(HttpHeaders.CONTENT_DISPOSITION, disposition)
                             .body(resource);
    }
}

3.Maven依赖

XML 复制代码
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>3.4.3</version>
        </dependency>


        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
        </dependency>

        <dependency>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
            <version>2.1.1</version>
        </dependency>
        
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.36</version>
        </dependency>

4.其他事项

注意:上述代码依赖 ffmpeg 工具,请先确保它已在您的系统中安装并配置好环境变量。如果您尚未安装,可参考官方文档或相关教程完成安装,本文不再详细介绍。

ffmpeg官网https://ffmpeg.org/

测试的话,请使用apifox或者其他方式进行测试,这里就不详细说明了。测试情况与我的另一篇文章类似。

使用asp.net core调用ffmpeg对视频进行截图截取添加水印https://blog.csdn.net/l244112311/article/details/157025206

相关推荐
Remember_9931 小时前
【数据结构】二叉树:从基础到应用全面解析
java·数据结构·b树·算法·leetcode·链表
C++chaofan2 小时前
JUC并发编程:LockSupport.park() 与 unpark() 深度解析
java·开发语言·c++·性能优化·高并发·juc
人工智能AI技术2 小时前
Java程序员如何入门AI
java·人工智能
我是小疯子662 小时前
C++图论:从基础到实战应用
java·c++·图论
小码过河.2 小时前
设计模式——享元模式
java·设计模式·享元模式
J_liaty2 小时前
深入理解Java反射:原理、应用与最佳实践
java·开发语言·反射
小冷coding2 小时前
【面试】围绕‌服务注册与发现、配置中心、熔断限流、API网关路由‌四大核心组件会面临哪些问题?
java·面试·职场和发展
张彦峰ZYF2 小时前
Java+Python双语言开发AI工具全景分析与选型指南
java·人工智能·python
可儿·四系桜2 小时前
Kafka从入门到精通:分布式消息队列实战指南(Zookeeper 模式)
java·开发语言·zookeeper·kafka