VideoThumbnailService.java 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. package com.ylx.massage.service.impl;
  2. import org.springframework.stereotype.Service;
  3. import org.springframework.web.multipart.MultipartFile;
  4. import lombok.extern.slf4j.Slf4j;
  5. import java.io.*;
  6. import java.nio.file.Files;
  7. import java.nio.file.Path;
  8. import java.nio.file.Paths;
  9. import java.util.ArrayList;
  10. import java.util.List;
  11. import java.util.UUID;
  12. import java.util.concurrent.TimeUnit;
  13. /**
  14. * 视频封面截取服务
  15. * 依赖 FFmpeg,需要提前在服务器安装 FFmpeg
  16. */
  17. @Slf4j
  18. @Service
  19. public class VideoThumbnailService {
  20. // 视频临时存储路径
  21. private static final String VIDEO_TEMP_PATH = "E:/tmp/videos/";
  22. // 封面图片存储路径
  23. private static final String THUMBNAIL_SAVE_PATH = "E:/tmp/thumbnails/";
  24. // FFmpeg 命令路径(Windows 下需要使用 .exe 扩展名)
  25. private static final String FFMPEG_PATH = "E:\\ffmpeg-8.0.1-essentials_build\\bin\\ffmpeg.exe";
  26. // 截取时间点(秒)
  27. private static final int CAPTURE_TIME_SECOND = 1;
  28. // 命令执行超时时间(秒)
  29. private static final int COMMAND_TIMEOUT = 30;
  30. /**
  31. * 从上传的视频文件中截取封面图
  32. *
  33. * @param videoFile 上传的视频文件
  34. * @return 封面图片的保存路径
  35. * @throws VideoProcessException 视频处理异常
  36. */
  37. public String extractThumbnail(MultipartFile videoFile) throws VideoProcessException {
  38. validateVideoFile(videoFile);
  39. String videoTempPath = null;
  40. try {
  41. // 1. 保存上传的视频文件到临时目录
  42. videoTempPath = saveVideoToTemp(videoFile);
  43. log.info("视频文件已保存到临时目录: {}", videoTempPath);
  44. // 2. 生成封面图片文件(该方法内部会执行 FFmpeg 命令并验证)
  45. String thumbnailFilePath = generateThumbnailFilePath(videoTempPath, videoFile.getOriginalFilename());
  46. return thumbnailFilePath;
  47. } catch (Exception e) {
  48. log.error("视频封面截取失败", e);
  49. throw new VideoProcessException("视频封面截取失败: " + e.getMessage(), e);
  50. } finally {
  51. // 清理临时视频文件
  52. if (videoTempPath != null) {
  53. deleteFileQuietly(videoTempPath);
  54. }
  55. }
  56. }
  57. /**
  58. * 验证上传的视频文件
  59. * <p>
  60. * 验证上传的视频文件是否符合要求,包括非空、文件扩展名和文件大小。
  61. * </p>
  62. *
  63. * @param videoFile 上传的视频文件
  64. * @throws VideoProcessException 如果视频文件为空、文件名无效、格式不支持或文件大小超过限制
  65. */
  66. private void validateVideoFile(MultipartFile videoFile) throws VideoProcessException {
  67. if (videoFile == null || videoFile.isEmpty()) {
  68. throw new VideoProcessException("视频文件不能为空");
  69. }
  70. String originalFilename = videoFile.getOriginalFilename();
  71. if (originalFilename == null || originalFilename.trim().isEmpty()) {
  72. throw new VideoProcessException("视频文件名不能为空");
  73. }
  74. // 验证文件扩展名
  75. String extension = getFileExtension(originalFilename).toLowerCase();
  76. if (!isValidVideoFormat(extension)) {
  77. throw new VideoProcessException("不支持的视频格式: " + extension);
  78. }
  79. // 验证文件大小(例如限制 500MB)
  80. long maxSize = 50 * 1024 * 1024L;
  81. if (videoFile.getSize() > maxSize) {
  82. throw new VideoProcessException("视频文件过大,最大支持 50MB");
  83. }
  84. }
  85. /**
  86. * 检查是否为支持的视频格式
  87. */
  88. private boolean isValidVideoFormat(String extension) {
  89. return extension.matches("mp4|avi|mov|mkv|flv|wmv|webm|m4v");
  90. }
  91. /**
  92. * 保存视频到临时目录
  93. * <p>
  94. * 将上传的视频文件保存到临时目录,确保目录存在并生成唯一的文件名。
  95. * </p>
  96. *
  97. * @param videoFile 上传的视频文件
  98. * @return String 视频文件在临时目录中的路径
  99. * @throws IOException 如果文件操作失败
  100. */
  101. private String saveVideoToTemp(MultipartFile videoFile) throws IOException {
  102. // 确保临时目录存在
  103. Path tempDir = Paths.get(VIDEO_TEMP_PATH);
  104. if (!Files.exists(tempDir)) {
  105. Files.createDirectories(tempDir);
  106. }
  107. // 生成唯一的临时文件名
  108. String extension = getFileExtension(videoFile.getOriginalFilename());
  109. String tempFileName = UUID.randomUUID().toString() + "." + extension;
  110. log.info("临时文件名: {}", tempFileName);
  111. Path tempFilePath = tempDir.resolve(tempFileName);
  112. // 保存文件
  113. try (InputStream inputStream = videoFile.getInputStream();
  114. OutputStream outputStream = Files.newOutputStream(tempFilePath)) {
  115. byte[] buffer = new byte[8192];
  116. int bytesRead;
  117. while ((bytesRead = inputStream.read(buffer)) != -1) {
  118. outputStream.write(buffer, 0, bytesRead);
  119. }
  120. }
  121. return tempFilePath.toString();
  122. }
  123. /**
  124. * 生成封面图片保存路径
  125. *
  126. * @param originalFilename 原始文件名
  127. * @return String 封面图片保存路径
  128. */
  129. private String generateThumbnailPath(String originalFilename) throws IOException {
  130. // 确保保存目录存在
  131. Path saveDir = Paths.get(THUMBNAIL_SAVE_PATH);
  132. if (!Files.exists(saveDir)) {
  133. Files.createDirectories(saveDir);
  134. }
  135. // 生成唯一的封面文件名
  136. String baseFilename = getFileNameWithoutExtension(originalFilename);
  137. String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + ".jpg";
  138. return saveDir.resolve(thumbnailFileName).toString();
  139. }
  140. /**
  141. * 生成封面图片文件到指定路径
  142. * <p>
  143. * 该方法会完整地执行以下操作:
  144. * 1. 确保保存目录存在
  145. * 2. 生成唯一的封面文件路径
  146. * 3. 使用 FFmpeg 从视频中截取封面图
  147. * 4. 验证生成的图片文件有效性
  148. * </p>
  149. *
  150. * @param videoPath 视频文件路径
  151. * @param originalFilename 原始视频文件名(用于生成缩略图文件名)
  152. * @return 生成的封面图片文件绝对路径
  153. * @throws IOException 如果目录创建失败
  154. * @throws VideoProcessException 如果封面生成失败或验证失败
  155. */
  156. private String generateThumbnailFilePath(String videoPath, String originalFilename) throws IOException, VideoProcessException {
  157. // 1. 确保保存目录存在
  158. Path saveDir = Paths.get(THUMBNAIL_SAVE_PATH);
  159. if (!Files.exists(saveDir)) {
  160. Files.createDirectories(saveDir);
  161. log.info("创建封面图片保存目录: {}", saveDir.toAbsolutePath());
  162. }
  163. // 2. 生成唯一的封面文件路径
  164. String baseFilename = getFileNameWithoutExtension(originalFilename);
  165. String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + "_thumbnail.jpg";
  166. Path thumbnailPath = saveDir.resolve(thumbnailFileName).toAbsolutePath();
  167. String thumbnailPathStr = thumbnailPath.toString();
  168. log.info("开始生成封面图片,视频: {}, 目标路径: {}", videoPath, thumbnailPathStr);
  169. // 3. 执行 FFmpeg 命令生成封面
  170. try {
  171. executeFfmpegCommand(videoPath, thumbnailPathStr);
  172. log.info("FFmpeg 命令执行成功");
  173. } catch (InterruptedException e) {
  174. Thread.currentThread().interrupt();
  175. throw new VideoProcessException("封面生成过程被中断", e);
  176. }
  177. // 4. 验证生成的封面文件
  178. validateThumbnailFile(thumbnailPathStr);
  179. log.info("封面图片生成成功: {}", thumbnailPathStr);
  180. return thumbnailPathStr;
  181. }
  182. /**
  183. * 执行 FFmpeg 命令截取封面
  184. */
  185. private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoProcessException {
  186. // 构建 FFmpeg 命令
  187. // -ss: 截取时间点
  188. // -i: 输入文件
  189. // -vframes 1: 只截取一帧
  190. // -q:v 2: 图片质量(1-31,数字越小质量越高)
  191. // -y: 覆盖输出文件
  192. List<String> command = new ArrayList<>();
  193. command.add(FFMPEG_PATH);
  194. command.add("-ss");
  195. command.add(String.valueOf(CAPTURE_TIME_SECOND));
  196. command.add("-i");
  197. command.add(videoPath);
  198. command.add("-vframes");
  199. command.add("1");
  200. command.add("-q:v");
  201. command.add("2");
  202. command.add("-y");
  203. command.add(thumbnailPath);
  204. log.info("执行FFmpeg命令: {}", String.join(" ", command));
  205. // 执行命令
  206. ProcessBuilder processBuilder = new ProcessBuilder(command);
  207. processBuilder.redirectErrorStream(true);
  208. Process process = processBuilder.start();
  209. // 读取命令输出(用于日志和调试)
  210. StringBuilder output = new StringBuilder();
  211. try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
  212. String line;
  213. while ((line = reader.readLine()) != null) {
  214. output.append(line).append("\n");
  215. }
  216. }
  217. // 等待命令执行完成(设置超时)
  218. boolean finished = process.waitFor(COMMAND_TIMEOUT, TimeUnit.SECONDS);
  219. if (!finished) {
  220. process.destroyForcibly();
  221. throw new VideoProcessException("FFmpeg 命令执行超时");
  222. }
  223. int exitCode = process.exitValue();
  224. if (exitCode != 0) {
  225. log.error("FFmpeg 执行失败,输出: {}", output);
  226. throw new VideoProcessException("FFmpeg 执行失败,退出码: " + exitCode);
  227. }
  228. log.debug("FFmpeg 输出: {}", output);
  229. }
  230. /**
  231. * 验证生成的封面文件
  232. */
  233. private void validateThumbnailFile(String thumbnailPath) throws VideoProcessException {
  234. File thumbnailFile = new File(thumbnailPath);
  235. if (!thumbnailFile.exists()) {
  236. throw new VideoProcessException("封面文件生成失败");
  237. }
  238. if (thumbnailFile.length() == 0) {
  239. throw new VideoProcessException("生成的封面文件为空");
  240. }
  241. // 可选:验证文件是否为有效的图片
  242. try {
  243. String mimeType = Files.probeContentType(thumbnailFile.toPath());
  244. if (mimeType == null || !mimeType.startsWith("image/")) {
  245. throw new VideoProcessException("生成的文件不是有效的图片");
  246. }
  247. } catch (IOException e) {
  248. log.warn("无法验证文件 MIME 类型", e);
  249. }
  250. }
  251. /**
  252. * 静默删除文件
  253. */
  254. private void deleteFileQuietly(String filePath) {
  255. try {
  256. Files.deleteIfExists(Paths.get(filePath));
  257. log.debug("已删除文件: {}", filePath);
  258. } catch (Exception e) {
  259. log.warn("删除文件失败: {}", filePath, e);
  260. }
  261. }
  262. /**
  263. * 获取文件扩展名
  264. *
  265. * @param filename 文件名
  266. * @return String 文件扩展名(不包含点号),如果文件名没有扩展名则返回空字符串
  267. */
  268. private String getFileExtension(String filename) {
  269. if (filename == null || filename.isEmpty()) {
  270. return "";
  271. }
  272. int lastDotIndex = filename.lastIndexOf('.');
  273. return lastDotIndex > 0 ? filename.substring(lastDotIndex + 1) : "";
  274. }
  275. /**
  276. * 获取不带扩展名的文件名
  277. */
  278. private String getFileNameWithoutExtension(String filename) {
  279. if (filename == null || filename.isEmpty()) {
  280. return UUID.randomUUID().toString();
  281. }
  282. int lastDotIndex = filename.lastIndexOf('.');
  283. return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
  284. }
  285. /**
  286. * 视频处理异常
  287. */
  288. public static class VideoProcessException extends Exception {
  289. public VideoProcessException(String message) {
  290. super(message);
  291. }
  292. public VideoProcessException(String message, Throwable cause) {
  293. super(message, cause);
  294. }
  295. }
  296. }