|
@@ -0,0 +1,296 @@
|
|
|
|
|
+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 命令路径(根据实际安装路径调整)
|
|
|
|
|
+ private static final String FFMPEG_PATH = "ffmpeg";
|
|
|
|
|
+ // 截取时间点(秒)
|
|
|
|
|
+ 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;
|
|
|
|
|
+ String thumbnailPath = null;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1. 保存上传的视频文件到临时目录
|
|
|
|
|
+ videoTempPath = saveVideoToTemp(videoFile);
|
|
|
|
|
+ log.info("视频文件已保存到临时目录: {}", videoTempPath);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 生成封面图片保存路径
|
|
|
|
|
+ thumbnailPath = generateThumbnailPath(videoFile.getOriginalFilename());
|
|
|
|
|
+ log.info("封面图片将保存到: {}", thumbnailPath);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 使用 FFmpeg 截取封面
|
|
|
|
|
+ executeFfmpegCommand(videoTempPath, thumbnailPath);
|
|
|
|
|
+ log.info("封面图片生成成功: {}", thumbnailPath);
|
|
|
|
|
+ // 4. 验证生成的封面文件
|
|
|
|
|
+ validateThumbnailFile(thumbnailPath);
|
|
|
|
|
+ return thumbnailPath;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ // 如果失败,删除可能生成的封面文件
|
|
|
|
|
+ /*if (thumbnailPath != null) {
|
|
|
|
|
+ deleteFileQuietly(thumbnailPath);
|
|
|
|
|
+ }*/
|
|
|
|
|
+ log.error("视频封面截取失败", e);
|
|
|
|
|
+ throw new VideoProcessException("视频封面截取失败: " + e.getMessage(), e);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ // 清理临时视频文件
|
|
|
|
|
+ /*if (videoTempPath != null) {
|
|
|
|
|
+ deleteFileQuietly(videoTempPath);
|
|
|
|
|
+ }*/
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证上传的视频文件
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 验证上传的视频文件是否符合要求,包括非空、文件扩展名和文件大小。
|
|
|
|
|
+ * </p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * @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");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 保存视频到临时目录
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 将上传的视频文件保存到临时目录,确保目录存在并生成唯一的文件名。
|
|
|
|
|
+ * </p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * @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();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 执行 FFmpeg 命令截取封面
|
|
|
|
|
+ */
|
|
|
|
|
+ private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoProcessException {
|
|
|
|
|
+ // 构建 FFmpeg 命令
|
|
|
|
|
+ // -ss: 截取时间点
|
|
|
|
|
+ // -i: 输入文件
|
|
|
|
|
+ // -vframes 1: 只截取一帧
|
|
|
|
|
+ // -q:v 2: 图片质量(1-31,数字越小质量越高)
|
|
|
|
|
+ // -y: 覆盖输出文件
|
|
|
|
|
+ List<String> 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|