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 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 files) { BatchThumbnailResult result = new BatchThumbnailResult(); List 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 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 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); } } }