package com.ruoyi.service.impl;
|
|
import com.ruoyi.common.config.RuoYiConfig;
|
import lombok.extern.slf4j.Slf4j;
|
import lombok.Data;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.stereotype.Service;
|
import org.springframework.web.multipart.MultipartFile;
|
import ws.schild.jave.*;
|
|
|
import java.io.*;
|
import java.nio.file.Files;
|
import java.nio.file.Path;
|
import java.nio.file.Paths;
|
import java.util.*;
|
import java.util.concurrent.CompletableFuture;
|
|
@Service
|
@Slf4j
|
public class VideoProcessService {
|
|
@Value("${video.upload-dir:./uploads/videos}")
|
private String uploadDir;
|
|
@Value("${video.thumbnail-dir:/profile/thumbnails}")
|
private String thumbnailDir;
|
|
@Value("${video.thumbnail.width:320}")
|
private int thumbnailWidth;
|
|
@Value("${video.thumbnail.height:240}")
|
private int thumbnailHeight;
|
|
@Value("${video.thumbnail.quality:5}")
|
private int thumbnailQuality; // 1-31,值越小质量越好
|
|
@Value("${video.thumbnail.format:jpg}")
|
private String thumbnailFormat;
|
|
@Value("${video.thumbnail.count:1}")
|
private int thumbnailCount; // 生成封面数量
|
|
// 支持的视频格式
|
private static final Set<String> SUPPORTED_VIDEO_FORMATS =
|
new HashSet<>(Arrays.asList("mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "mpeg", "mpg", "3gp", "m4v"));
|
|
/**
|
* 处理视频文件:只生成封面
|
*/
|
public VideoProcessResult processVideo(MultipartFile file) {
|
VideoProcessResult result = new VideoProcessResult();
|
|
try {
|
// 1. 验证视频格式
|
if (!isVideoFile(file)) {
|
result.setSuccess(false);
|
result.setMessage("不支持的视频格式");
|
return result;
|
}
|
|
// 2. 生成唯一文件名
|
String originalFilename = file.getOriginalFilename();
|
String fileExtension = getFileExtension(originalFilename);
|
String uuid = UUID.randomUUID().toString().replace("-", "");
|
String fileName = uuid + fileExtension;
|
|
// 3. 创建目录
|
createDirectories();
|
|
// 4. 保存原始文件
|
Path originalPath = Paths.get(uploadDir, fileName);
|
file.transferTo(originalPath.toFile());
|
|
// 5. 获取视频信息
|
MultimediaInfo info = getVideoInfo(originalPath.toFile());
|
|
// 设置结果
|
result.setSuccess(true);
|
result.setMessage("封面生成成功");
|
result.setOriginalPath(originalPath.toString());
|
result.setOriginalFilename(originalFilename);
|
result.setFileSize(file.getSize());
|
result.setDuration(info.getDuration() / 1000.0); // 转换为秒
|
result.setResolution(info.getVideo().getSize().getWidth() + "x" + info.getVideo().getSize().getHeight());
|
result.setVideoFormat(info.getFormat());
|
|
// 6. 生成封面
|
String thumbnailPath = generateThumbnail(originalPath.toFile().getPath());
|
result.setThumbnailPath(thumbnailPath);
|
|
} catch (Exception e) {
|
log.error("视频封面生成失败", e);
|
result.setSuccess(false);
|
result.setMessage("封面生成失败: " + e.getMessage());
|
}
|
|
return result;
|
}
|
|
/**
|
* 检查是否为支持的视频文件
|
*/
|
private boolean isVideoFile(MultipartFile file) {
|
if (file == null || file.isEmpty()) {
|
return false;
|
}
|
|
String fileName = file.getOriginalFilename();
|
if (fileName == null) {
|
return false;
|
}
|
|
String extension = getFileExtension(fileName).toLowerCase();
|
return SUPPORTED_VIDEO_FORMATS.contains(extension);
|
}
|
|
/**
|
* 检查是否为支持的视频文件
|
*/
|
private boolean isVideoFile(File file) {
|
if (file == null || !file.exists()) {
|
return false;
|
}
|
|
String fileName = file.getName();
|
String extension = getFileExtension(fileName).toLowerCase();
|
return SUPPORTED_VIDEO_FORMATS.contains(extension);
|
}
|
|
/**
|
* 生成封面(缩略图)
|
*/
|
public String generateThumbnail(String videoPath) throws Exception {
|
File videoFile = new File(RuoYiConfig.getProfile() + videoPath.replace("/profile", ""));
|
if (!videoFile.exists()) {
|
throw new FileNotFoundException("视频文件不存在: " + videoPath);
|
}
|
|
// 生成唯一的缩略图文件名(与视频同目录)
|
String originalName = videoFile.getName();
|
String baseName = getFileNameWithoutExtension(originalName);
|
String thumbnailName = "thumb_" + baseName + "_" +
|
UUID.randomUUID().toString().substring(0, 8) +
|
"." + thumbnailFormat;
|
|
// *** 关键修改:缩略图生成在视频文件所在目录 ***
|
// 获取视频文件所在的目录
|
File videoDir = videoFile.getParentFile();
|
if (videoDir == null) {
|
throw new RuntimeException("无法获取视频文件所在目录: " + videoFile.getAbsolutePath());
|
}
|
|
// 确保视频目录存在
|
if (!videoDir.exists()) {
|
videoDir.mkdirs();
|
}
|
|
// 创建缩略图文件对象(在视频同目录)
|
File thumbnailFile = new File(videoDir, thumbnailName);
|
|
System.out.println("------------------------缩略图文件名: " + thumbnailName);
|
System.out.println("------------------------缩略图完整路径: " + thumbnailFile.getAbsolutePath());
|
System.out.println("------------------------视频文件目录: " + videoDir.getAbsolutePath());
|
|
// 1. 获取视频时长,计算截图时间点
|
MultimediaInfo info = getVideoInfo(videoFile);
|
double durationSeconds = info.getDuration() / 1000.0;
|
double screenshotTime = calculateScreenshotTime(durationSeconds);
|
System.out.println("------------------------截图时间点: " + screenshotTime);
|
|
// 2. 构建并执行 FFmpeg 命令
|
String ffmpegCmd = String.format(
|
"ffmpeg -i \"%s\" -ss %.2f -vframes 1 -q:v %d -s %dx%d \"%s\"",
|
videoFile.getAbsolutePath(), // 输入文件
|
screenshotTime, // 跳转到的时间点(秒)
|
thumbnailQuality, // 输出质量 (2-31,值越小质量越高)
|
thumbnailWidth, // 输出宽度
|
thumbnailHeight, // 输出高度
|
thumbnailFile.getAbsolutePath() // 输出文件(与视频同目录)
|
);
|
|
|
Process process = Runtime.getRuntime().exec(ffmpegCmd);
|
|
// 3. 读取并打印命令输出(用于调试)
|
try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
|
BufferedReader inputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
|
String line;
|
while ((line = errorReader.readLine()) != null) {
|
System.out.println("[FFmpeg Error] " + line);
|
}
|
while ((line = inputReader.readLine()) != null) {
|
System.out.println("[FFmpeg Output] " + line);
|
}
|
}
|
|
int exitCode = process.waitFor();
|
System.out.println("------------------------FFmpeg 退出码: " + exitCode);
|
|
if (exitCode != 0) {
|
throw new RuntimeException("FFmpeg 截图失败,退出码: " + exitCode);
|
}
|
|
if (!thumbnailFile.exists() || thumbnailFile.length() == 0) {
|
throw new RuntimeException("生成的缩略图为空或不存在");
|
}
|
|
// 获取缩略图的绝对路径
|
String absolutePath = thumbnailFile.getAbsolutePath();
|
|
// 将绝对路径转换为相对profilePath的相对路径
|
String relativePath = absolutePath.substring(RuoYiConfig.getProfile().length()).replace("\\", "/");
|
|
// 确保相对路径以 / 开头
|
if (!relativePath.startsWith("/")) {
|
relativePath = "/" + relativePath;
|
}
|
|
System.out.println("------------------------生成的相对路径: " + relativePath);
|
|
return "/profile"+relativePath;
|
}
|
|
/**
|
* 计算截图时间点
|
*/
|
private double calculateScreenshotTime(double durationSeconds) {
|
// 1. 尝试在第1秒
|
// 2. 如果视频不足1秒,在中间截图
|
// 3. 如果视频很短(<0.5秒),在开头截图
|
double screenshotTime = 1.0; // 默认第1秒
|
if (durationSeconds < 1.0) {
|
screenshotTime = durationSeconds / 2; // 中间
|
}
|
if (durationSeconds < 0.5) {
|
screenshotTime = 0; // 开头
|
}
|
return screenshotTime;
|
}
|
|
/**
|
* 获取视频信息
|
*/
|
private MultimediaInfo getVideoInfo(File videoFile) throws Exception {
|
MultimediaObject multimediaObject = new MultimediaObject(videoFile);
|
return multimediaObject.getInfo();
|
}
|
|
|
/**
|
* 批量生成缩略图
|
*/
|
public BatchThumbnailResult batchGenerateThumbnails(List<MultipartFile> files) {
|
BatchThumbnailResult result = new BatchThumbnailResult();
|
List<VideoProcessResult> results = new ArrayList<>();
|
|
int successCount = 0;
|
int failCount = 0;
|
|
for (MultipartFile file : files) {
|
try {
|
VideoProcessResult singleResult = processVideo(file);
|
results.add(singleResult);
|
|
if (singleResult.isSuccess()) {
|
successCount++;
|
} else {
|
failCount++;
|
}
|
|
} catch (Exception e) {
|
log.error("处理视频失败: {}", file.getOriginalFilename(), e);
|
failCount++;
|
}
|
}
|
|
result.setResults(results);
|
result.setTotal(files.size());
|
result.setSuccess(successCount);
|
result.setFail(failCount);
|
|
return result;
|
}
|
|
/**
|
* 从视频目录批量生成缩略图
|
*/
|
public void generateThumbnailsFromDirectory(File videoDir) throws Exception {
|
if (!videoDir.exists() || !videoDir.isDirectory()) {
|
throw new IllegalArgumentException("视频目录不存在");
|
}
|
|
File[] videoFiles = videoDir.listFiles((dir, name) -> isVideoFile(new File(dir, name)));
|
|
if (videoFiles == null || videoFiles.length == 0) {
|
log.warn("视频目录中没有找到支持的视频文件");
|
return;
|
}
|
|
log.info("开始批量生成缩略图,共{}个视频", videoFiles.length);
|
|
int successCount = 0;
|
int failCount = 0;
|
|
for (File videoFile : videoFiles) {
|
try {
|
generateThumbnail(videoFile.getPath());
|
successCount++;
|
log.info("已生成缩略图: {}", videoFile.getName());
|
} catch (Exception e) {
|
failCount++;
|
log.error("生成缩略图失败: {}", videoFile.getName(), e);
|
}
|
}
|
|
log.info("批量生成缩略图完成,成功{}个,失败{}个", successCount, failCount);
|
}
|
|
/**
|
* 获取视频时长
|
*/
|
public long getVideoDuration(File videoFile) throws Exception {
|
MultimediaInfo info = getVideoInfo(videoFile);
|
return info.getDuration() / 1000; // 返回秒数
|
}
|
|
/**
|
* 获取视频分辨率
|
*/
|
public String getVideoResolution(File videoFile) throws Exception {
|
MultimediaInfo info = getVideoInfo(videoFile);
|
VideoSize size = info.getVideo().getSize();
|
return size.getWidth() + "x" + size.getHeight();
|
}
|
|
/**
|
* 获取视频详细信息
|
*/
|
public VideoInfo getVideoInfoDetails(File videoFile) throws Exception {
|
VideoInfo info = new VideoInfo();
|
MultimediaInfo multimediaInfo = getVideoInfo(videoFile);
|
|
info.setFileName(videoFile.getName());
|
info.setFileSize(videoFile.length());
|
info.setDuration(multimediaInfo.getDuration() / 1000.0);
|
info.setResolution(multimediaInfo.getVideo().getSize().getWidth() + "x" +
|
multimediaInfo.getVideo().getSize().getHeight());
|
info.setVideoFormat(multimediaInfo.getFormat());
|
info.setBitRate(multimediaInfo.getVideo().getBitRate());
|
info.setFrameRate(multimediaInfo.getVideo().getFrameRate());
|
|
return info;
|
}
|
|
/**
|
* 获取文件扩展名
|
*/
|
private String getFileExtension(String fileName) {
|
if (fileName == null) return "";
|
int lastDot = fileName.lastIndexOf('.');
|
if (lastDot > 0 && lastDot < fileName.length() - 1) {
|
return fileName.substring(lastDot + 1);
|
}
|
return "";
|
}
|
|
/**
|
* 获取不带扩展名的文件名
|
*/
|
private String getFileNameWithoutExtension(String fileName) {
|
if (fileName == null) return "";
|
int lastDot = fileName.lastIndexOf('.');
|
if (lastDot > 0) {
|
return fileName.substring(0, lastDot);
|
}
|
return fileName;
|
}
|
|
/**
|
* 根据图片格式获取编码器
|
*/
|
private String getCodecForFormat(String format) {
|
switch (format.toLowerCase()) {
|
case "jpg":
|
case "jpeg":
|
return "mjpeg";
|
case "png":
|
return "png";
|
case "gif":
|
return "gif";
|
case "bmp":
|
return "bmp";
|
default:
|
return "png";
|
}
|
}
|
|
/**
|
* 根据扩展名获取格式
|
*/
|
private String getFormatForExtension(String extension) {
|
switch (extension.toLowerCase()) {
|
case "jpg":
|
case "jpeg":
|
return "image2";
|
case "png":
|
return "image2";
|
case "gif":
|
return "gif";
|
case "bmp":
|
return "bmp";
|
default:
|
return "image2";
|
}
|
}
|
|
/**
|
* 创建目录
|
*/
|
private void createDirectories() throws IOException {
|
Files.createDirectories(Paths.get(uploadDir));
|
Files.createDirectories(Paths.get(thumbnailDir));
|
}
|
|
@Data
|
public static class VideoProcessResult {
|
private boolean success;
|
private String message;
|
private String originalPath;
|
private String originalFilename;
|
private String thumbnailPath;
|
private List<String> thumbnailPaths; // 多个封面路径
|
private long fileSize; // 原始文件大小
|
private double duration; // 视频时长(秒)
|
private String resolution; // 视频分辨率
|
private String videoFormat; // 视频格式
|
|
public String getFileSizeFormatted() {
|
return formatFileSize(fileSize);
|
}
|
|
public String getDurationFormatted() {
|
return formatDuration(duration);
|
}
|
}
|
|
@Data
|
public static class BatchThumbnailResult {
|
private List<VideoProcessResult> results;
|
private int total;
|
private int success;
|
private int fail;
|
|
public String getSuccessRate() {
|
if (total == 0) return "0%";
|
return String.format("%.1f%%", (success * 100.0) / total);
|
}
|
}
|
|
@Data
|
public static class VideoInfo {
|
private String fileName;
|
private long fileSize;
|
private double duration; // 秒
|
private String resolution;
|
private String videoFormat;
|
private int bitRate; // 比特率
|
private float frameRate; // 帧率
|
|
public String getFileSizeFormatted() {
|
return formatFileSize(fileSize);
|
}
|
|
public String getDurationFormatted() {
|
return formatDuration(duration);
|
}
|
}
|
|
/**
|
* 格式化文件大小
|
*/
|
public static String formatFileSize(long size) {
|
if (size < 1024) {
|
return size + " B";
|
} else if (size < 1024 * 1024) {
|
return String.format("%.1f KB", size / 1024.0);
|
} else if (size < 1024 * 1024 * 1024) {
|
return String.format("%.1f MB", size / (1024.0 * 1024.0));
|
} else {
|
return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0));
|
}
|
}
|
|
/**
|
* 格式化时长
|
*/
|
public static String formatDuration(double seconds) {
|
if (seconds < 60) {
|
return String.format("%.1f秒", seconds);
|
} else if (seconds < 3600) {
|
int minutes = (int) (seconds / 60);
|
double remainingSeconds = seconds % 60;
|
return String.format("%d分%.1f秒", minutes, remainingSeconds);
|
} else {
|
int hours = (int) (seconds / 3600);
|
int minutes = (int) ((seconds % 3600) / 60);
|
double remainingSeconds = seconds % 60;
|
return String.format("%d小时%d分%.1f秒", hours, minutes, remainingSeconds);
|
}
|
}
|
}
|