package com.ylx.massage.service.impl; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * 视频封面截取服务 * 依赖 FFmpeg,需要提前在服务器安装 FFmpeg */ @Slf4j @Service public class VideoThumbnailService { // 视频临时存储路径 private static final String VIDEO_TEMP_PATH = "E:/tmp/videos/"; // 封面图片存储路径 private static final String THUMBNAIL_SAVE_PATH = "E:/tmp/thumbnails/"; // FFmpeg 命令路径(Windows 下需要使用 .exe 扩展名) private static final String FFMPEG_PATH = "E:\\ffmpeg-8.0.1-essentials_build\\bin\\ffmpeg.exe"; // 截取时间点(秒) private static final int CAPTURE_TIME_SECOND = 1; // 命令执行超时时间(秒) private static final int COMMAND_TIMEOUT = 30; /** * 从上传的视频文件中截取封面图 * * @param videoFile 上传的视频文件 * @return 封面图片的保存路径 * @throws VideoProcessException 视频处理异常 */ public String extractThumbnail(MultipartFile videoFile) throws VideoProcessException { validateVideoFile(videoFile); String videoTempPath = null; try { // 1. 保存上传的视频文件到临时目录 videoTempPath = saveVideoToTemp(videoFile); log.info("视频文件已保存到临时目录: {}", videoTempPath); // 2. 生成封面图片文件(该方法内部会执行 FFmpeg 命令并验证) String thumbnailFilePath = generateThumbnailFilePath(videoTempPath, videoFile.getOriginalFilename()); return thumbnailFilePath; } catch (Exception e) { log.error("视频封面截取失败", e); throw new VideoProcessException("视频封面截取失败: " + e.getMessage(), e); } finally { // 清理临时视频文件 if (videoTempPath != null) { deleteFileQuietly(videoTempPath); } } } /** * 验证上传的视频文件 *

* 验证上传的视频文件是否符合要求,包括非空、文件扩展名和文件大小。 *

* * @param videoFile 上传的视频文件 * @throws VideoProcessException 如果视频文件为空、文件名无效、格式不支持或文件大小超过限制 */ private void validateVideoFile(MultipartFile videoFile) throws VideoProcessException { if (videoFile == null || videoFile.isEmpty()) { throw new VideoProcessException("视频文件不能为空"); } String originalFilename = videoFile.getOriginalFilename(); if (originalFilename == null || originalFilename.trim().isEmpty()) { throw new VideoProcessException("视频文件名不能为空"); } // 验证文件扩展名 String extension = getFileExtension(originalFilename).toLowerCase(); if (!isValidVideoFormat(extension)) { throw new VideoProcessException("不支持的视频格式: " + extension); } // 验证文件大小(例如限制 500MB) long maxSize = 50 * 1024 * 1024L; if (videoFile.getSize() > maxSize) { throw new VideoProcessException("视频文件过大,最大支持 50MB"); } } /** * 检查是否为支持的视频格式 */ private boolean isValidVideoFormat(String extension) { return extension.matches("mp4|avi|mov|mkv|flv|wmv|webm|m4v"); } /** * 保存视频到临时目录 *

* 将上传的视频文件保存到临时目录,确保目录存在并生成唯一的文件名。 *

* * @param videoFile 上传的视频文件 * @return String 视频文件在临时目录中的路径 * @throws IOException 如果文件操作失败 */ private String saveVideoToTemp(MultipartFile videoFile) throws IOException { // 确保临时目录存在 Path tempDir = Paths.get(VIDEO_TEMP_PATH); if (!Files.exists(tempDir)) { Files.createDirectories(tempDir); } // 生成唯一的临时文件名 String extension = getFileExtension(videoFile.getOriginalFilename()); String tempFileName = UUID.randomUUID().toString() + "." + extension; log.info("临时文件名: {}", tempFileName); Path tempFilePath = tempDir.resolve(tempFileName); // 保存文件 try (InputStream inputStream = videoFile.getInputStream(); OutputStream outputStream = Files.newOutputStream(tempFilePath)) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } return tempFilePath.toString(); } /** * 生成封面图片保存路径 * * @param originalFilename 原始文件名 * @return String 封面图片保存路径 */ private String generateThumbnailPath(String originalFilename) throws IOException { // 确保保存目录存在 Path saveDir = Paths.get(THUMBNAIL_SAVE_PATH); if (!Files.exists(saveDir)) { Files.createDirectories(saveDir); } // 生成唯一的封面文件名 String baseFilename = getFileNameWithoutExtension(originalFilename); String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + ".jpg"; return saveDir.resolve(thumbnailFileName).toString(); } /** * 生成封面图片文件到指定路径 *

* 该方法会完整地执行以下操作: * 1. 确保保存目录存在 * 2. 生成唯一的封面文件路径 * 3. 使用 FFmpeg 从视频中截取封面图 * 4. 验证生成的图片文件有效性 *

* * @param videoPath 视频文件路径 * @param originalFilename 原始视频文件名(用于生成缩略图文件名) * @return 生成的封面图片文件绝对路径 * @throws IOException 如果目录创建失败 * @throws VideoProcessException 如果封面生成失败或验证失败 */ private String generateThumbnailFilePath(String videoPath, String originalFilename) throws IOException, VideoProcessException { // 1. 确保保存目录存在 Path saveDir = Paths.get(THUMBNAIL_SAVE_PATH); if (!Files.exists(saveDir)) { Files.createDirectories(saveDir); log.info("创建封面图片保存目录: {}", saveDir.toAbsolutePath()); } // 2. 生成唯一的封面文件路径 String baseFilename = getFileNameWithoutExtension(originalFilename); String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + "_thumbnail.jpg"; Path thumbnailPath = saveDir.resolve(thumbnailFileName).toAbsolutePath(); String thumbnailPathStr = thumbnailPath.toString(); log.info("开始生成封面图片,视频: {}, 目标路径: {}", videoPath, thumbnailPathStr); // 3. 执行 FFmpeg 命令生成封面 try { executeFfmpegCommand(videoPath, thumbnailPathStr); log.info("FFmpeg 命令执行成功"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new VideoProcessException("封面生成过程被中断", e); } // 4. 验证生成的封面文件 validateThumbnailFile(thumbnailPathStr); log.info("封面图片生成成功: {}", thumbnailPathStr); return thumbnailPathStr; } /** * 执行 FFmpeg 命令截取封面 */ private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoProcessException { // 构建 FFmpeg 命令 // -ss: 截取时间点 // -i: 输入文件 // -vframes 1: 只截取一帧 // -q:v 2: 图片质量(1-31,数字越小质量越高) // -y: 覆盖输出文件 List command = new ArrayList<>(); command.add(FFMPEG_PATH); command.add("-ss"); command.add(String.valueOf(CAPTURE_TIME_SECOND)); command.add("-i"); command.add(videoPath); command.add("-vframes"); command.add("1"); command.add("-q:v"); command.add("2"); command.add("-y"); command.add(thumbnailPath); log.info("执行FFmpeg命令: {}", String.join(" ", command)); // 执行命令 ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.redirectErrorStream(true); Process process = processBuilder.start(); // 读取命令输出(用于日志和调试) StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } // 等待命令执行完成(设置超时) boolean finished = process.waitFor(COMMAND_TIMEOUT, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new VideoProcessException("FFmpeg 命令执行超时"); } int exitCode = process.exitValue(); if (exitCode != 0) { log.error("FFmpeg 执行失败,输出: {}", output); throw new VideoProcessException("FFmpeg 执行失败,退出码: " + exitCode); } log.debug("FFmpeg 输出: {}", output); } /** * 验证生成的封面文件 */ private void validateThumbnailFile(String thumbnailPath) throws VideoProcessException { File thumbnailFile = new File(thumbnailPath); if (!thumbnailFile.exists()) { throw new VideoProcessException("封面文件生成失败"); } if (thumbnailFile.length() == 0) { throw new VideoProcessException("生成的封面文件为空"); } // 可选:验证文件是否为有效的图片 try { String mimeType = Files.probeContentType(thumbnailFile.toPath()); if (mimeType == null || !mimeType.startsWith("image/")) { throw new VideoProcessException("生成的文件不是有效的图片"); } } catch (IOException e) { log.warn("无法验证文件 MIME 类型", e); } } /** * 静默删除文件 */ private void deleteFileQuietly(String filePath) { try { Files.deleteIfExists(Paths.get(filePath)); log.debug("已删除文件: {}", filePath); } catch (Exception e) { log.warn("删除文件失败: {}", filePath, e); } } /** * 获取文件扩展名 * * @param filename 文件名 * @return String 文件扩展名(不包含点号),如果文件名没有扩展名则返回空字符串 */ private String getFileExtension(String filename) { if (filename == null || filename.isEmpty()) { return ""; } int lastDotIndex = filename.lastIndexOf('.'); return lastDotIndex > 0 ? filename.substring(lastDotIndex + 1) : ""; } /** * 获取不带扩展名的文件名 */ private String getFileNameWithoutExtension(String filename) { if (filename == null || filename.isEmpty()) { return UUID.randomUUID().toString(); } int lastDotIndex = filename.lastIndexOf('.'); return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename; } /** * 视频处理异常 */ public static class VideoProcessException extends Exception { public VideoProcessException(String message) { super(message); } public VideoProcessException(String message, Throwable cause) { super(message, cause); } } }