| | |
| | | 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.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.nio.file.Path; |
| | | import java.nio.file.Paths; |
| | | |
| | | import java.util.*; |
| | | import java.util.concurrent.CompletableFuture; |
| | | import java.util.Base64; |
| | | import java.util.List; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | @Service |
| | | @Slf4j |
| | |
| | | |
| | | @Value("${video.upload-dir:./uploads/videos}") |
| | | private String uploadDir; |
| | | |
| | | @Value("${video.thumbnail-dir:/profile/thumbnails}") |
| | | private String thumbnailDir; |
| | | |
| | | @Value("${video.thumbnail.width:320}") |
| | | private int thumbnailWidth; |
| | |
| | | @Value("${video.thumbnail.format:jpg}") |
| | | private String thumbnailFormat; |
| | | |
| | | @Value("${video.thumbnail.count:1}") |
| | | private int thumbnailCount; // 生成封面数量 |
| | | |
| | | // 支持的视频格式 |
| | | private static final Set<String> 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 VideoProcessResult processVideo(MultipartFile file) { |
| | | VideoProcessResult result = new VideoProcessResult(); |
| | | public Map<String, Object> processImage(File file, int width, int height, |
| | | float quality, String format,boolean deleteY) throws IOException { |
| | | Map<String, Object> 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<ImageWriter> 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<ImageWriter> 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<String, Object> processVideo(File file, int width, int height, |
| | | float quality, String format,boolean deleteY) { |
| | | Map<String, Object> result = new HashMap<>(); |
| | | File tempVideoFile = file; |
| | | |
| | | |
| | | System.out.println(); |
| | | |
| | | try { |
| | | // 1. 验证视频格式 |
| | | if (!isVideoFile(file)) { |
| | | result.setSuccess(false); |
| | | result.setMessage("不支持的视频格式"); |
| | | result.put("success", false); |
| | | result.put("message", "不支持的视频格式"); |
| | | return result; |
| | | } |
| | | |
| | | // 2. 生成唯一文件名 |
| | | String originalFilename = file.getOriginalFilename(); |
| | | String fileExtension = getFileExtension(originalFilename); |
| | | String uuid = UUID.randomUUID().toString().replace("-", ""); |
| | | String fileName = uuid + fileExtension; |
| | | // 2. 保存原始文件到临时目录 |
| | | // String originalFilename = file.getName(); |
| | | // tempVideoFile = File.createTempFile("video_", ".temp"); |
| | | |
| | | // 3. 创建目录 |
| | | createDirectories(); |
| | | // 3. 获取视频信息 |
| | | // MultimediaInfo info = getVideoInfo(tempVideoFile); |
| | | // double durationSeconds = info.getDuration() / 1000.0; |
| | | // String videoResolution = info.getVideo().getSize().getWidth() + "x" + info.getVideo().getSize().getHeight(); |
| | | |
| | | // 4. 保存原始文件 |
| | | Path originalPath = Paths.get(uploadDir, fileName); |
| | | file.transferTo(originalPath.toFile()); |
| | | // 4. 生成Base64格式的封面 |
| | | String base64Thumbnail = generateBase64ThumbnailFromFile( |
| | | tempVideoFile, |
| | | width > 0 ? width : thumbnailWidth, |
| | | height > 0 ? height : thumbnailHeight, |
| | | (int) quality, |
| | | format != null ? format : thumbnailFormat |
| | | ); |
| | | |
| | | // 5. 获取视频信息 |
| | | MultimediaInfo info = getVideoInfo(originalPath.toFile()); |
| | | if (base64Thumbnail == null || base64Thumbnail.isEmpty()) { |
| | | result.put("success", false); |
| | | result.put("message", "封面生成失败"); |
| | | return result; |
| | | } |
| | | |
| | | // 设置结果 |
| | | result.setSuccess(true); |
| | | result.setMessage("封面生成成功"); |
| | | result.setOriginalPath(originalPath.toString()); |
| | | result.setOriginalFilename(originalFilename); |
| | | result.setFileSize(file.getSize()); |
| | | result.setDuration(info.getDuration() / 1000.0); // 转换为秒 |
| | | result.setResolution(info.getVideo().getSize().getWidth() + "x" + info.getVideo().getSize().getHeight()); |
| | | result.setVideoFormat(info.getFormat()); |
| | | // 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. 生成封面 |
| | | String thumbnailPath = generateThumbnail(originalPath.toFile().getPath()); |
| | | result.setThumbnailPath(thumbnailPath); |
| | | // 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.setSuccess(false); |
| | | result.setMessage("封面生成失败: " + e.getMessage()); |
| | | 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()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 生成封面(缩略图) |
| | | * 批量生成Base64格式的缩略图 |
| | | */ |
| | | public String generateThumbnail(String videoPath) throws Exception { |
| | | File videoFile = new File(RuoYiConfig.getProfile() + videoPath.replace("/profile", "")); |
| | | if (!videoFile.exists()) { |
| | | throw new FileNotFoundException("视频文件不存在: " + videoPath); |
| | | } |
| | | |
| | | // 生成唯一的缩略图文件名(与视频同目录) |
| | | String originalName = videoFile.getName(); |
| | | String baseName = getFileNameWithoutExtension(originalName); |
| | | String thumbnailName = "thumb_" + baseName + "_" + |
| | | UUID.randomUUID().toString().substring(0, 8) + |
| | | "." + thumbnailFormat; |
| | | |
| | | // *** 关键修改:缩略图生成在视频文件所在目录 *** |
| | | // 获取视频文件所在的目录 |
| | | File videoDir = videoFile.getParentFile(); |
| | | if (videoDir == null) { |
| | | throw new RuntimeException("无法获取视频文件所在目录: " + videoFile.getAbsolutePath()); |
| | | } |
| | | |
| | | // 确保视频目录存在 |
| | | if (!videoDir.exists()) { |
| | | videoDir.mkdirs(); |
| | | } |
| | | |
| | | // 创建缩略图文件对象(在视频同目录) |
| | | File thumbnailFile = new File(videoDir, thumbnailName); |
| | | |
| | | System.out.println("------------------------缩略图文件名: " + thumbnailName); |
| | | System.out.println("------------------------缩略图完整路径: " + thumbnailFile.getAbsolutePath()); |
| | | System.out.println("------------------------视频文件目录: " + videoDir.getAbsolutePath()); |
| | | |
| | | // 1. 获取视频时长,计算截图时间点 |
| | | MultimediaInfo info = getVideoInfo(videoFile); |
| | | double durationSeconds = info.getDuration() / 1000.0; |
| | | double screenshotTime = calculateScreenshotTime(durationSeconds); |
| | | System.out.println("------------------------截图时间点: " + screenshotTime); |
| | | |
| | | // 2. 构建并执行 FFmpeg 命令 |
| | | String ffmpegCmd = String.format( |
| | | "ffmpeg -i \"%s\" -ss %.2f -vframes 1 -q:v %d -s %dx%d \"%s\"", |
| | | videoFile.getAbsolutePath(), // 输入文件 |
| | | screenshotTime, // 跳转到的时间点(秒) |
| | | thumbnailQuality, // 输出质量 (2-31,值越小质量越高) |
| | | thumbnailWidth, // 输出宽度 |
| | | thumbnailHeight, // 输出高度 |
| | | thumbnailFile.getAbsolutePath() // 输出文件(与视频同目录) |
| | | ); |
| | | |
| | | |
| | | Process process = Runtime.getRuntime().exec(ffmpegCmd); |
| | | |
| | | // 3. 读取并打印命令输出(用于调试) |
| | | try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); |
| | | BufferedReader inputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { |
| | | |
| | | String line; |
| | | while ((line = errorReader.readLine()) != null) { |
| | | System.out.println("[FFmpeg Error] " + line); |
| | | } |
| | | while ((line = inputReader.readLine()) != null) { |
| | | System.out.println("[FFmpeg Output] " + line); |
| | | } |
| | | } |
| | | |
| | | int exitCode = process.waitFor(); |
| | | System.out.println("------------------------FFmpeg 退出码: " + exitCode); |
| | | |
| | | if (exitCode != 0) { |
| | | throw new RuntimeException("FFmpeg 截图失败,退出码: " + exitCode); |
| | | } |
| | | |
| | | if (!thumbnailFile.exists() || thumbnailFile.length() == 0) { |
| | | throw new RuntimeException("生成的缩略图为空或不存在"); |
| | | } |
| | | |
| | | // 获取缩略图的绝对路径 |
| | | String absolutePath = thumbnailFile.getAbsolutePath(); |
| | | |
| | | // 将绝对路径转换为相对profilePath的相对路径 |
| | | String relativePath = absolutePath.substring(RuoYiConfig.getProfile().length()).replace("\\", "/"); |
| | | |
| | | // 确保相对路径以 / 开头 |
| | | if (!relativePath.startsWith("/")) { |
| | | relativePath = "/" + relativePath; |
| | | } |
| | | |
| | | System.out.println("------------------------生成的相对路径: " + relativePath); |
| | | |
| | | return "/profile"+relativePath; |
| | | } |
| | | |
| | | /** |
| | | * 计算截图时间点 |
| | | */ |
| | | private double calculateScreenshotTime(double durationSeconds) { |
| | | // 1. 尝试在第1秒 |
| | | // 2. 如果视频不足1秒,在中间截图 |
| | | // 3. 如果视频很短(<0.5秒),在开头截图 |
| | | double screenshotTime = 1.0; // 默认第1秒 |
| | | if (durationSeconds < 1.0) { |
| | | screenshotTime = durationSeconds / 2; // 中间 |
| | | } |
| | | if (durationSeconds < 0.5) { |
| | | screenshotTime = 0; // 开头 |
| | | } |
| | | return screenshotTime; |
| | | } |
| | | |
| | | /** |
| | | * 获取视频信息 |
| | | */ |
| | | private MultimediaInfo getVideoInfo(File videoFile) throws Exception { |
| | | MultimediaObject multimediaObject = new MultimediaObject(videoFile); |
| | | return multimediaObject.getInfo(); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * 批量生成缩略图 |
| | | */ |
| | | public BatchThumbnailResult batchGenerateThumbnails(List<MultipartFile> files) { |
| | | BatchThumbnailResult result = new BatchThumbnailResult(); |
| | | List<VideoProcessResult> 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); |
| | | failCount++; |
| | | } |
| | | } |
| | | |
| | | result.setResults(results); |
| | | result.setTotal(files.size()); |
| | | result.setSuccess(successCount); |
| | | result.setFail(failCount); |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 从视频目录批量生成缩略图 |
| | | */ |
| | | public void generateThumbnailsFromDirectory(File videoDir) throws Exception { |
| | | if (!videoDir.exists() || !videoDir.isDirectory()) { |
| | | throw new IllegalArgumentException("视频目录不存在"); |
| | | } |
| | | |
| | | File[] videoFiles = videoDir.listFiles((dir, name) -> isVideoFile(new File(dir, name))); |
| | | |
| | | if (videoFiles == null || videoFiles.length == 0) { |
| | | log.warn("视频目录中没有找到支持的视频文件"); |
| | | return; |
| | | } |
| | | |
| | | log.info("开始批量生成缩略图,共{}个视频", videoFiles.length); |
| | | |
| | | int successCount = 0; |
| | | int failCount = 0; |
| | | |
| | | for (File videoFile : videoFiles) { |
| | | try { |
| | | generateThumbnail(videoFile.getPath()); |
| | | successCount++; |
| | | log.info("已生成缩略图: {}", videoFile.getName()); |
| | | } catch (Exception e) { |
| | | failCount++; |
| | | log.error("生成缩略图失败: {}", videoFile.getName(), e); |
| | | } |
| | | } |
| | | |
| | | log.info("批量生成缩略图完成,成功{}个,失败{}个", successCount, failCount); |
| | | } |
| | | // public BatchThumbnailResult batchGenerateThumbnails(List<MultipartFile> files) { |
| | | // BatchThumbnailResult result = new BatchThumbnailResult(); |
| | | // List<VideoProcessResult> 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; |
| | | // } |
| | | |
| | | /** |
| | | * 获取视频时长 |
| | |
| | | if (fileName == null) return ""; |
| | | int lastDot = fileName.lastIndexOf('.'); |
| | | if (lastDot > 0 && lastDot < fileName.length() - 1) { |
| | | return fileName.substring(lastDot + 1); |
| | | return fileName.substring(lastDot + 1).toLowerCase(); |
| | | } |
| | | return ""; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 根据图片格式获取编码器 |
| | | * 验证图片是否有效 |
| | | */ |
| | | private String getCodecForFormat(String format) { |
| | | 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 "mjpeg"; |
| | | return "image/jpeg"; |
| | | case "png": |
| | | return "png"; |
| | | return "image/png"; |
| | | case "gif": |
| | | return "gif"; |
| | | return "image/gif"; |
| | | case "bmp": |
| | | return "bmp"; |
| | | return "image/bmp"; |
| | | case "webp": |
| | | return "image/webp"; |
| | | default: |
| | | return "png"; |
| | | return "image/jpeg"; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据扩展名获取格式 |
| | | * 判断操作系统是否是Windows |
| | | */ |
| | | private String getFormatForExtension(String extension) { |
| | | switch (extension.toLowerCase()) { |
| | | case "jpg": |
| | | case "jpeg": |
| | | return "image2"; |
| | | case "png": |
| | | return "image2"; |
| | | case "gif": |
| | | return "gif"; |
| | | case "bmp": |
| | | return "bmp"; |
| | | default: |
| | | return "image2"; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 创建目录 |
| | | */ |
| | | private void createDirectories() throws IOException { |
| | | Files.createDirectories(Paths.get(uploadDir)); |
| | | Files.createDirectories(Paths.get(thumbnailDir)); |
| | | 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 originalPath; |
| | | private String originalFilename; |
| | | private String thumbnailPath; |
| | | private List<String> thumbnailPaths; // 多个封面路径 |
| | | 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); |
| | |
| | | } |
| | | } |
| | | } |
| | | |