| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- 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);
- }
- }
- }
- /**
- * 验证上传的视频文件
- * <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();
- }
- /**
- * 生成封面图片文件到指定路径
- * <p>
- * 该方法会完整地执行以下操作:
- * 1. 确保保存目录存在
- * 2. 生成唯一的封面文件路径
- * 3. 使用 FFmpeg 从视频中截取封面图
- * 4. 验证生成的图片文件有效性
- * </p>
- *
- * @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<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);
- }
- }
- }
|