package com.ruoyi.service.impl; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import ws.schild.jave.*; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; import javax.imageio.stream.MemoryCacheImageOutputStream; import java.awt.*; import java.awt.image.BufferedImage; import java.io.*; import java.nio.file.Files; import java.util.*; import java.util.Base64; import java.util.List; import java.util.concurrent.TimeUnit; @Service @Slf4j public class VideoProcessService { @Value("${video.upload-dir:./uploads/videos}") private String uploadDir; @Value("${video.thumbnail.width:320}") private int thumbnailWidth; @Value("${video.thumbnail.height:240}") private int thumbnailHeight; @Value("${video.thumbnail.quality:5}") private int thumbnailQuality; // 1-31,值越小质量越好 @Value("${video.thumbnail.format:jpg}") private String thumbnailFormat; // 支持的视频格式 private static final Set SUPPORTED_VIDEO_FORMATS = new HashSet<>(Arrays.asList("mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "mpeg", "mpg", "3gp", "m4v")); public File convertToFile(MultipartFile multipartFile) throws IOException { String originalFilename = multipartFile.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); File tempFile = File.createTempFile("video_", extension); multipartFile.transferTo(tempFile); tempFile.deleteOnExit(); return tempFile; } /** * 处理图片文件 */ public Map processImage(File file, int width, int height, float quality, String format,boolean deleteY) throws IOException { Map result = new HashMap<>(); // 读取图片 BufferedImage image = ImageIO.read(file); if (image == null) { result.put("success", false); result.put("message", "无法读取图片文件"); return result; } int originalWidth = image.getWidth(); int originalHeight = image.getHeight(); // 调整尺寸 if (width > 0 && height > 0) { image = resizeImage(image, width, height, true); } // 压缩并转换为Base64 byte[] compressedBytes = compressImage(image, quality, format); String base64 = Base64.getEncoder().encodeToString(compressedBytes); String dataUrl = "data:image/" + format + ";base64," + base64; // 返回结果 result.put("success", true); result.put("type", "image"); result.put("format", format); result.put("compressedSize", compressedBytes.length); result.put("compressedWidth", width > 0 ? width : originalWidth); result.put("compressedHeight", height > 0 ? height : originalHeight); result.put("base64", dataUrl); result.put("message", "图片压缩成功"); if (deleteY) { file.delete(); result.put("originalDeleted", false); result.put("message", "图片压缩成功"); } return result; } /** * 调整图片大小 */ private BufferedImage resizeImage(BufferedImage originalImage, int targetWidth, int targetHeight, boolean keepAspectRatio) { int originalWidth = originalImage.getWidth(); int originalHeight = originalImage.getHeight(); // 如果宽高都为0,则不调整 if (targetWidth <= 0 && targetHeight <= 0) { return originalImage; } int newWidth, newHeight; if (keepAspectRatio) { // 保持宽高比 if (targetWidth <= 0) { // 只给高度 double scale = (double) targetHeight / originalHeight; newWidth = (int) (originalWidth * scale); newHeight = targetHeight; } else if (targetHeight <= 0) { // 只给宽度 double scale = (double) targetWidth / originalWidth; newWidth = targetWidth; newHeight = (int) (originalHeight * scale); } else { // 两个维度都给了,保持宽高比缩放 double widthRatio = (double) targetWidth / originalWidth; double heightRatio = (double) targetHeight / originalHeight; double scale = Math.min(widthRatio, heightRatio); newWidth = (int) (originalWidth * scale); newHeight = (int) (originalHeight * scale); } } else { // 不保持宽高比 newWidth = targetWidth > 0 ? targetWidth : originalWidth; newHeight = targetHeight > 0 ? targetHeight : originalHeight; } // 确保最小为1 newWidth = Math.max(1, newWidth); newHeight = Math.max(1, newHeight); BufferedImage resizedImage = new BufferedImage(newWidth, newHeight, originalImage.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : originalImage.getType()); Graphics2D g = resizedImage.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.drawImage(originalImage, 0, 0, newWidth, newHeight, null); g.dispose(); return resizedImage; } /** * 压缩图片 */ private byte[] compressImage(BufferedImage image, float quality, String format) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); // 根据格式选择压缩方式 if ("jpg".equalsIgnoreCase(format) || "jpeg".equalsIgnoreCase(format)) { compressAsJpeg(image, baos, quality); } else if ("png".equalsIgnoreCase(format)) { compressAsPng(image, baos, 6); // PNG压缩级别6 } else { // 其他格式使用默认方式 ImageIO.write(image, format, baos); } return baos.toByteArray(); } /** * 压缩为JPEG格式 */ private void compressAsJpeg(BufferedImage image, ByteArrayOutputStream output, float quality) throws IOException { Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); if (!writers.hasNext()) { ImageIO.write(image, "jpg", output); return; } ImageWriter writer = writers.next(); ImageWriteParam param = writer.getDefaultWriteParam(); // 设置压缩质量 if (quality > 0) { param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(Math.max(0.1f, Math.min(1.0f, quality))); } writer.setOutput(new MemoryCacheImageOutputStream(output)); writer.write(null, new IIOImage(image, null, null), param); writer.dispose(); } /** * 压缩为PNG格式 */ private void compressAsPng(BufferedImage image, ByteArrayOutputStream output, int compressionLevel) throws IOException { Iterator writers = ImageIO.getImageWritersByFormatName("png"); if (!writers.hasNext()) { ImageIO.write(image, "png", output); return; } ImageWriter writer = writers.next(); ImageWriteParam param = writer.getDefaultWriteParam(); // PNG的压缩级别(0-9,0最快但压缩率低,9最慢但压缩率高) if (compressionLevel >= 0) { param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionType("Deflate"); param.setCompressionQuality(Math.max(0.0f, Math.min(1.0f, compressionLevel / 9.0f))); } writer.setOutput(new MemoryCacheImageOutputStream(output)); writer.write(null, new IIOImage(image, null, null), param); writer.dispose(); } /** * 处理视频文件:生成Base64编码的封面 */ public Map processVideo(File file, int width, int height, float quality, String format,boolean deleteY) { Map result = new HashMap<>(); File tempVideoFile = file; System.out.println(); try { // 1. 验证视频格式 if (!isVideoFile(file)) { result.put("success", false); result.put("message", "不支持的视频格式"); return result; } // 2. 保存原始文件到临时目录 // String originalFilename = file.getName(); // tempVideoFile = File.createTempFile("video_", ".temp"); // 3. 获取视频信息 // MultimediaInfo info = getVideoInfo(tempVideoFile); // double durationSeconds = info.getDuration() / 1000.0; // String videoResolution = info.getVideo().getSize().getWidth() + "x" + info.getVideo().getSize().getHeight(); // 4. 生成Base64格式的封面 String base64Thumbnail = generateBase64ThumbnailFromFile( tempVideoFile, width > 0 ? width : thumbnailWidth, height > 0 ? height : thumbnailHeight, (int) quality, format != null ? format : thumbnailFormat ); if (base64Thumbnail == null || base64Thumbnail.isEmpty()) { result.put("success", false); result.put("message", "封面生成失败"); return result; } // 5. 获取缩略图大小 String thumbnailSize = extractImageSizeFromBase64(base64Thumbnail); String[] sizeParts = thumbnailSize.split("x"); int thumbWidth = sizeParts.length > 0 ? Integer.parseInt(sizeParts[0]) : 0; int thumbHeight = sizeParts.length > 1 ? Integer.parseInt(sizeParts[1]) : 0; // 6. 返回结果 result.put("success", true); result.put("type", "video"); result.put("format", format != null ? format : thumbnailFormat); // result.put("originalFilename", originalFilename); // result.put("originalWidth", info.getVideo().getSize().getWidth()); // result.put("originalHeight", info.getVideo().getSize().getHeight()); // result.put("originalResolution", videoResolution); // result.put("duration", durationSeconds); // result.put("videoFormat", info.getFormat()); // result.put("compressedWidth", thumbWidth); // result.put("compressedHeight", thumbHeight); result.put("base64", base64Thumbnail); result.put("message", "视频封面生成成功"); } catch (Exception e) { log.error("视频封面生成失败", e); result.put("success", false); result.put("message", "封面生成失败: " + e.getMessage()); } finally { // 清理临时文件 if (tempVideoFile != null && tempVideoFile.exists() && deleteY) { try { tempVideoFile.delete(); } catch (Exception e) { log.warn("删除临时视频文件时出错", e); } } } return result; } /** * 生成Base64格式的封面(可自定义参数) */ private String generateBase64ThumbnailFromFile(File videoFile, int width, int height, int quality, String format) { File tempThumbnailFile = null; Process process = null; try { if (!videoFile.exists()) { log.error("视频文件不存在: {}", videoFile.getAbsolutePath()); return null; } // 1. 创建临时文件用于保存缩略图 String ext = format != null ? format : thumbnailFormat; tempThumbnailFile = File.createTempFile("thumb_", "." + ext); tempThumbnailFile.deleteOnExit(); // 2. 获取视频时长,计算截图时间点 MultimediaInfo info = getVideoInfo(videoFile); double durationSeconds = info.getDuration() / 1000.0; double screenshotTime = calculateScreenshotTime(durationSeconds); // 3. 构建FFmpeg命令 String ffmpegCmd = String.format( "ffmpeg -y -i \"%s\" -ss %.2f -vframes 1 -q:v %d -s %dx%d -f image2 \"%s\"", videoFile.getAbsolutePath(), screenshotTime, quality, width, height, tempThumbnailFile.getAbsolutePath() ); // 4. 执行FFmpeg命令 ProcessBuilder processBuilder = new ProcessBuilder(); String[] command = isWindows() ? new String[]{"cmd.exe", "/c", ffmpegCmd} : new String[]{"bash", "-c", ffmpegCmd}; processBuilder.command(command); processBuilder.redirectErrorStream(true); process = processBuilder.start(); // 5. 等待命令完成 boolean finished = process.waitFor(30, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); return null; } int exitCode = process.waitFor(); if (exitCode != 0 || !tempThumbnailFile.exists() || tempThumbnailFile.length() == 0) { return null; } // 6. 读取图片文件并转换为Base64 byte[] fileContent = Files.readAllBytes(tempThumbnailFile.toPath()); if (!isValidImage(fileContent)) { return null; } // 7. 转换为Base64 String base64 = Base64.getEncoder().encodeToString(fileContent); String mimeType = getMimeType(ext); return "data:" + mimeType + ";base64," + base64; } catch (Exception e) { log.error("生成Base64封面失败", e); return null; } finally { // 清理资源 if (process != null && process.isAlive()) { process.destroy(); } if (tempThumbnailFile != null && tempThumbnailFile.exists()) { try { tempThumbnailFile.delete(); } catch (Exception e) { // ignore } } } } /** * 生成Base64格式的封面(不存储文件) */ public String generateBase64ThumbnailFromFile(String videoPath) { File videoFile = new File(videoPath); if (!videoFile.exists()) { log.error("视频文件不存在: {}", videoPath); return null; } return generateBase64ThumbnailFromFile(videoFile); } /** * 生成Base64格式的封面(不存储文件) */ public String generateBase64ThumbnailFromFile(File videoFile) { File tempThumbnailFile = null; Process process = null; try { if (!videoFile.exists()) { log.error("视频文件不存在: {}", videoFile.getAbsolutePath()); return null; } // 1. 创建临时文件用于保存缩略图 tempThumbnailFile = File.createTempFile("thumb_", "." + thumbnailFormat); tempThumbnailFile.deleteOnExit(); // 确保文件会被删除 // 2. 获取视频时长,计算截图时间点 MultimediaInfo info = getVideoInfo(videoFile); double durationSeconds = info.getDuration() / 1000.0; double screenshotTime = calculateScreenshotTime(durationSeconds); log.debug("视频截图信息 - 文件名: {}, 时长: {}s, 截图时间点: {}s, 目标尺寸: {}x{}, 质量: {}", videoFile.getName(), durationSeconds, screenshotTime, thumbnailWidth, thumbnailHeight, thumbnailQuality); // 3. 构建FFmpeg命令 String ffmpegCmd = String.format( "ffmpeg -y -i \"%s\" -ss %.2f -vframes 1 -q:v %d -s %dx%d -f image2 \"%s\"", videoFile.getAbsolutePath(), // 输入文件 screenshotTime, // 跳转到的时间点(秒) thumbnailQuality, // 输出质量 (2-31,值越小质量越高) thumbnailWidth, // 输出宽度 thumbnailHeight, // 输出高度 tempThumbnailFile.getAbsolutePath() // 输出文件 ); log.debug("执行FFmpeg命令: {}", ffmpegCmd); // 4. 执行FFmpeg命令 ProcessBuilder processBuilder = new ProcessBuilder(); String[] command = isWindows() ? new String[]{"cmd.exe", "/c", ffmpegCmd} : new String[]{"bash", "-c", ffmpegCmd}; processBuilder.command(command); processBuilder.redirectErrorStream(true); process = processBuilder.start(); // 5. 读取输出(用于调试) 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"); } } // 6. 等待命令完成(带超时) boolean finished = process.waitFor(30, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); log.error("FFmpeg命令执行超时,视频文件: {}", videoFile.getName()); return null; } int exitCode = process.waitFor(); if (exitCode != 0) { log.error("FFmpeg命令执行失败,退出码: {}, 视频文件: {}, 输出: {}", exitCode, videoFile.getName(), output.toString()); return null; } // 7. 检查生成的图片文件 if (!tempThumbnailFile.exists() || tempThumbnailFile.length() == 0) { log.error("生成的缩略图为空或不存在,视频文件: {}", videoFile.getName()); return null; } // 8. 读取图片文件并转换为Base64 byte[] fileContent = Files.readAllBytes(tempThumbnailFile.toPath()); // 9. 验证图片有效性 if (!isValidImage(fileContent)) { log.error("生成的缩略图不是有效的图片,视频文件: {}", videoFile.getName()); return null; } // 10. 转换为Base64 String base64 = Base64.getEncoder().encodeToString(fileContent); String mimeType = getMimeType(thumbnailFormat); return "data:" + mimeType + ";base64," + base64; } catch (Exception e) { log.error("生成Base64封面失败,视频文件: {}", videoFile.getName(), e); return null; } finally { // 清理资源 if (process != null && process.isAlive()) { process.destroy(); } // 删除临时文件 if (tempThumbnailFile != null && tempThumbnailFile.exists()) { try { boolean deleted = tempThumbnailFile.delete(); if (deleted) { log.debug("已删除临时缩略图文件: {}", tempThumbnailFile.getAbsolutePath()); } else { log.warn("无法删除临时缩略图文件: {}", tempThumbnailFile.getAbsolutePath()); } } catch (Exception e) { log.warn("删除临时缩略图文件时出错", e); } } } } /** * 从Base64字符串中提取图片尺寸 */ private String extractImageSizeFromBase64(String base64) { try { // Base64字符串格式: data:image/jpeg;base64,/9j/4AAQSkZJRgABA... String base64Data = base64.split(",")[1]; byte[] imageBytes = Base64.getDecoder().decode(base64Data); try (ByteArrayInputStream bis = new ByteArrayInputStream(imageBytes)) { BufferedImage image = ImageIO.read(bis); if (image != null) { return image.getWidth() + "x" + image.getHeight(); } } } catch (Exception e) { log.warn("无法从Base64中提取图片尺寸", e); } return thumbnailWidth + "x" + thumbnailHeight; // 返回目标尺寸 } /** * 计算截图时间点 */ private double calculateScreenshotTime(double durationSeconds) { if (durationSeconds <= 0) { return 0; } // 如果视频长度小于1秒,在中间截图 if (durationSeconds < 1) { return durationSeconds / 2; } // 如果视频长度小于3秒,在第1秒截图 if (durationSeconds < 3) { return 1.0; } // 视频较长时,在10%的位置截图(但不超过30秒) double time = durationSeconds * 0.1; return Math.min(time, 30.0); } /** * 获取视频信息 */ private MultimediaInfo getVideoInfo(File videoFile) throws Exception { try { MultimediaObject multimediaObject = new MultimediaObject(videoFile); return multimediaObject.getInfo(); } catch (Exception e) { log.error("获取视频信息失败: {}", videoFile.getAbsolutePath(), e); throw new Exception("无法获取视频信息: " + e.getMessage()); } } /** * 检查是否为支持的视频文件 */ private boolean isVideoFile(MultipartFile file) { if (file == null || file.isEmpty()) { return false; } String fileName = file.getOriginalFilename(); if (fileName == null) { return false; } String extension = getFileExtension(fileName).toLowerCase(); return SUPPORTED_VIDEO_FORMATS.contains(extension); } /** * 检查是否为支持的视频文件 */ private boolean isVideoFile(File file) { if (file == null || !file.exists()) { return false; } String fileName = file.getName(); String extension = getFileExtension(fileName).toLowerCase(); return SUPPORTED_VIDEO_FORMATS.contains(extension); } /** * 批量生成Base64格式的缩略图 */ // public BatchThumbnailResult batchGenerateThumbnails(List files) { // BatchThumbnailResult result = new BatchThumbnailResult(); // List results = new ArrayList<>(); // // int successCount = 0; // int failCount = 0; // // for (MultipartFile file : files) { // try { // VideoProcessResult singleResult = processVideo(file); // results.add(singleResult); // // if (singleResult.isSuccess()) { // successCount++; // } else { // failCount++; // } // // } catch (Exception e) { // log.error("处理视频失败: {}", file.getOriginalFilename(), e); // // VideoProcessResult errorResult = new VideoProcessResult(); // errorResult.setSuccess(false); // errorResult.setMessage("处理失败: " + e.getMessage()); // errorResult.setOriginalFilename(file.getOriginalFilename()); // results.add(errorResult); // // failCount++; // } // } // // result.setResults(results); // result.setTotal(files.size()); // result.setSuccess(successCount); // result.setFail(failCount); // // return result; // } /** * 获取视频时长 */ public long getVideoDuration(File videoFile) throws Exception { MultimediaInfo info = getVideoInfo(videoFile); return info.getDuration() / 1000; // 返回秒数 } /** * 获取视频分辨率 */ public String getVideoResolution(File videoFile) throws Exception { MultimediaInfo info = getVideoInfo(videoFile); VideoSize size = info.getVideo().getSize(); return size.getWidth() + "x" + size.getHeight(); } /** * 获取视频详细信息 */ public VideoInfo getVideoInfoDetails(File videoFile) throws Exception { VideoInfo info = new VideoInfo(); MultimediaInfo multimediaInfo = getVideoInfo(videoFile); info.setFileName(videoFile.getName()); info.setFileSize(videoFile.length()); info.setDuration(multimediaInfo.getDuration() / 1000.0); info.setResolution(multimediaInfo.getVideo().getSize().getWidth() + "x" + multimediaInfo.getVideo().getSize().getHeight()); info.setVideoFormat(multimediaInfo.getFormat()); info.setBitRate(multimediaInfo.getVideo().getBitRate()); info.setFrameRate(multimediaInfo.getVideo().getFrameRate()); return info; } /** * 获取文件扩展名 */ private String getFileExtension(String fileName) { if (fileName == null) return ""; int lastDot = fileName.lastIndexOf('.'); if (lastDot > 0 && lastDot < fileName.length() - 1) { return fileName.substring(lastDot + 1).toLowerCase(); } return ""; } /** * 获取不带扩展名的文件名 */ private String getFileNameWithoutExtension(String fileName) { if (fileName == null) return ""; int lastDot = fileName.lastIndexOf('.'); if (lastDot > 0) { return fileName.substring(0, lastDot); } return fileName; } /** * 验证图片是否有效 */ private boolean isValidImage(byte[] imageData) { try (ByteArrayInputStream bis = new ByteArrayInputStream(imageData)) { BufferedImage image = ImageIO.read(bis); return image != null; } catch (Exception e) { return false; } } /** * 获取MIME类型 */ private String getMimeType(String format) { switch (format.toLowerCase()) { case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "bmp": return "image/bmp"; case "webp": return "image/webp"; default: return "image/jpeg"; } } /** * 判断操作系统是否是Windows */ private boolean isWindows() { String os = System.getProperty("os.name").toLowerCase(); return os.contains("win"); } @Data public static class VideoProcessResult { private boolean success; private String message; private String originalFilename; private String thumbnailBase64; // Base64编码的封面 private String mimeType; // 封面MIME类型 private long fileSize; // 原始文件大小 private double duration; // 视频时长(秒) private String resolution; // 视频分辨率 private String videoFormat; // 视频格式 private String thumbnailSize; // 缩略图尺寸 public String getFileSizeFormatted() { return formatFileSize(fileSize); } public String getDurationFormatted() { return formatDuration(duration); } } @Data public static class BatchThumbnailResult { private List results; private int total; private int success; private int fail; public String getSuccessRate() { if (total == 0) return "0%"; return String.format("%.1f%%", (success * 100.0) / total); } } @Data public static class VideoInfo { private String fileName; private long fileSize; private double duration; // 秒 private String resolution; private String videoFormat; private int bitRate; // 比特率 private float frameRate; // 帧率 public String getFileSizeFormatted() { return formatFileSize(fileSize); } public String getDurationFormatted() { return formatDuration(duration); } } /** * 格式化文件大小 */ public static String formatFileSize(long size) { if (size < 1024) { return size + " B"; } else if (size < 1024 * 1024) { return String.format("%.1f KB", size / 1024.0); } else if (size < 1024 * 1024 * 1024) { return String.format("%.1f MB", size / (1024.0 * 1024.0)); } else { return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0)); } } /** * 格式化时长 */ public static String formatDuration(double seconds) { if (seconds < 60) { return String.format("%.1f秒", seconds); } else if (seconds < 3600) { int minutes = (int) (seconds / 60); double remainingSeconds = seconds % 60; return String.format("%d分%.1f秒", minutes, remainingSeconds); } else { int hours = (int) (seconds / 3600); int minutes = (int) ((seconds % 3600) / 60); double remainingSeconds = seconds % 60; return String.format("%d小时%d分%.1f秒", hours, minutes, remainingSeconds); } } }