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