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 工具,请先确保它已在您的系统中安装并配置好环境变量。如果您尚未安装,可参考官方文档或相关教程完成安装,本文不再详细介绍。
测试的话,请使用apifox或者其他方式进行测试,这里就不详细说明了。测试情况与我的另一篇文章类似。
使用asp.net core调用ffmpeg对视频进行截图截取添加水印
https://blog.csdn.net/l244112311/article/details/157025206