SpringBoot調用ffmpeg實現(xiàn)對視頻的截圖、截取與水印
更新時間:2026年01月18日 13:35:46 作者:冷冷的菜哥
這篇文章主要介紹了如何使用Asp.Net Core調用ffmpeg對視頻進行處理,包括截圖、截取和添加水印,強調了ffmpeg工具的安裝和配置,并建議使用apifox或其他方式進行測試,需要的朋友可以參考下
1.視頻處理服務
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);
/**
* 根據不同系統(tǒng)創(chuàng)建空路徑
*/
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();
}
/**
* 執(zhí)行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命令執(zhí)行失敗,退出碼==={}", exitCode);
throw new RuntimeException("FFmpeg 命令執(zhí)行失敗,退出碼: " + 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"); // 輸出純數(shù)字,如 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("當前操作系統(tǒng)==={}", 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,數(shù)量==={}", 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.視頻處理控制器
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 間隔秒數(shù)
* @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("獲取截圖參數(shù)=={}, 時間=={}", 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依賴
<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 工具,請先確保它已在您的系統(tǒng)中安裝并配置好環(huán)境變量。如果您尚未安裝,可參考官方文檔或相關教程完成安裝,本文不再詳細介紹。
以上就是SpringBoot調用ffmpeg實現(xiàn)對視頻的截圖、截取與水印的詳細內容,更多關于SpringBoot ffmpeg視頻的截圖、截取與水印的資料請關注腳本之家其它相關文章!
相關文章
Springboot中的@ConditionalOnBean注解使用指南與最佳實踐(工作原理)
在使用Spring Boot進行開發(fā)時,大家應該都聽說過條件注解Conditional Annotations,下面給大家聊聊Springboot中的@ConditionalOnBean注解使用指南與最佳實踐,感興趣的朋友一起看看吧2025-05-05
springcloud Zuul動態(tài)路由的實現(xiàn)
這篇文章主要介紹了springcloud Zuul動態(tài)路由的實現(xiàn),詳細的介紹了什么是Zuu及其動態(tài)路由的實現(xiàn),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-11-11
java POI 如何實現(xiàn)Excel單元格內容換行
這篇文章主要介紹了java POI 如何實現(xiàn)Excel單元格內容換行的操作,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-07-07
mybatis動態(tài)SQL?if的test寫法及規(guī)則詳解
這篇文章主要介紹了mybatis動態(tài)SQL?if的test寫法及規(guī)則詳解,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-01-01
Netty分布式server啟動流程Nio創(chuàng)建源碼分析
這篇文章主要介紹了Netty分布式server啟動流程Nio創(chuàng)建源碼分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-03-03

