|
@@ -0,0 +1,443 @@
|
|
|
+package com.ruoyi.common.utils.image;
|
|
|
+
|
|
|
+import net.coobird.thumbnailator.Thumbnails;
|
|
|
+import org.apache.commons.io.FileUtils;
|
|
|
+import org.apache.commons.io.FilenameUtils;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import javax.imageio.ImageIO;
|
|
|
+import java.awt.image.BufferedImage;
|
|
|
+import java.io.File;
|
|
|
+import java.io.IOException;
|
|
|
+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.concurrent.*;
|
|
|
+import java.util.concurrent.atomic.AtomicInteger;
|
|
|
+import java.util.concurrent.atomic.AtomicLong;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 图片批量压缩工具类
|
|
|
+ * 支持批量处理多个文件夹
|
|
|
+ */
|
|
|
+public class ImageCompressor {
|
|
|
+
|
|
|
+ private static final Logger logger = LoggerFactory.getLogger(ImageCompressor.class);
|
|
|
+
|
|
|
+ // 默认配置
|
|
|
+ private static final String[] DEFAULT_SUPPORTED_FORMATS = {"jpg", "jpeg", "png", "bmp", "gif"};
|
|
|
+ private static final long DEFAULT_TARGET_SIZE_KB = 200; // 默认目标文件大小:200KB
|
|
|
+ private static final int DEFAULT_THREAD_POOL_SIZE = 1;
|
|
|
+ private static final int DEFAULT_BATCH_SIZE = 100;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 压缩结果统计
|
|
|
+ */
|
|
|
+ public static class CompressionResult {
|
|
|
+ private final int totalFiles;
|
|
|
+ private final int successCount;
|
|
|
+ private final int failCount;
|
|
|
+ private final int skipCount;
|
|
|
+ private final long totalOriginalSize;
|
|
|
+ private final long totalCompressedSize;
|
|
|
+ private final double compressionRatio;
|
|
|
+
|
|
|
+ public CompressionResult(int totalFiles, int successCount, int failCount, int skipCount,
|
|
|
+ long totalOriginalSize, long totalCompressedSize) {
|
|
|
+ this.totalFiles = totalFiles;
|
|
|
+ this.successCount = successCount;
|
|
|
+ this.failCount = failCount;
|
|
|
+ this.skipCount = skipCount;
|
|
|
+ this.totalOriginalSize = totalOriginalSize;
|
|
|
+ this.totalCompressedSize = totalCompressedSize;
|
|
|
+ this.compressionRatio = totalOriginalSize > 0 ?
|
|
|
+ (totalOriginalSize - totalCompressedSize) / (double) totalOriginalSize * 100 : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Getters
|
|
|
+ public int getTotalFiles() { return totalFiles; }
|
|
|
+ public int getSuccessCount() { return successCount; }
|
|
|
+ public int getFailCount() { return failCount; }
|
|
|
+ public int getSkipCount() { return skipCount; }
|
|
|
+ public long getTotalOriginalSize() { return totalOriginalSize; }
|
|
|
+ public long getTotalCompressedSize() { return totalCompressedSize; }
|
|
|
+ public double getCompressionRatio() { return compressionRatio; }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量压缩多个文件夹中的图片
|
|
|
+ *
|
|
|
+ * @param directoryPaths 文件夹路径列表
|
|
|
+ * @return 压缩结果统计
|
|
|
+ */
|
|
|
+ public static CompressionResult compressDirectories(List<String> directoryPaths) {
|
|
|
+ return compressDirectories(directoryPaths, DEFAULT_TARGET_SIZE_KB, DEFAULT_THREAD_POOL_SIZE, DEFAULT_BATCH_SIZE, DEFAULT_SUPPORTED_FORMATS, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量压缩多个文件夹中的图片(带参数)
|
|
|
+ *
|
|
|
+ * @param directoryPaths 文件夹路径列表
|
|
|
+ * @param targetSizeKB 目标文件大小(KB)
|
|
|
+ * @param threadPoolSize 线程池大小
|
|
|
+ * @param batchSize 批处理大小
|
|
|
+ * @param supportedFormats 支持的图片格式
|
|
|
+ * @param enableDetailedLog 是否启用详细日志
|
|
|
+ * @return 压缩结果统计
|
|
|
+ */
|
|
|
+ public static CompressionResult compressDirectories(List<String> directoryPaths, long targetSizeKB, int threadPoolSize, int batchSize, String[] supportedFormats, boolean enableDetailedLog) {
|
|
|
+ if (directoryPaths == null || directoryPaths.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("文件夹路径列表不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ long targetSizeBytes = targetSizeKB * 1024;
|
|
|
+ long minSizeToCompress = targetSizeBytes;
|
|
|
+
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("🖼️ 图片批量压缩工具启动");
|
|
|
+ logger.info("目标文件夹数量: {}", directoryPaths.size());
|
|
|
+ logger.info("目标文件大小: {}KB", targetSizeKB);
|
|
|
+ logger.info("多线程配置: {} 个线程", threadPoolSize);
|
|
|
+ logger.info("批处理大小: {} 个文件/批", batchSize);
|
|
|
+ logger.info("支持的格式: {}", String.join(", ", supportedFormats));
|
|
|
+ logger.info("==========================================");
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 收集所有图片文件
|
|
|
+ List<File> allImageFiles = new ArrayList<>();
|
|
|
+ List<String> validDirectories = new ArrayList<>();
|
|
|
+
|
|
|
+ for (String directoryPath : directoryPaths) {
|
|
|
+ Path path = Paths.get(directoryPath);
|
|
|
+ if (!Files.exists(path) || !Files.isDirectory(path)) {
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.warn("⚠️ 跳过无效路径: {}", directoryPath);
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ validDirectories.add(directoryPath);
|
|
|
+ List<File> imageFiles = findImageFiles(path.toFile(), supportedFormats);
|
|
|
+ allImageFiles.addAll(imageFiles);
|
|
|
+
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("📁 {} - 找到 {} 个图片文件", directoryPath, imageFiles.size());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (allImageFiles.isEmpty()) {
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.warn("⚠️ 在指定路径下未找到支持的图片文件");
|
|
|
+ }
|
|
|
+ return new CompressionResult(0, 0, 0, 0, 0, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("📁 总计找到 {} 个图片文件", allImageFiles.size());
|
|
|
+ logger.info("🚀 启动多线程压缩处理...");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开始多线程压缩
|
|
|
+ return processImageFiles(allImageFiles, targetSizeKB, targetSizeBytes, minSizeToCompress, threadPoolSize, batchSize, enableDetailedLog);
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("❌ 压缩过程中发生错误: {}", e.getMessage(), e);
|
|
|
+ throw new RuntimeException("图片压缩失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理图片文件列表
|
|
|
+ */
|
|
|
+ private static CompressionResult processImageFiles(List<File> imageFiles, long targetSizeKB, long targetSizeBytes, long minSizeToCompress, int threadPoolSize, int batchSize, boolean enableDetailedLog) {
|
|
|
+ // 创建线程池
|
|
|
+ ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize);
|
|
|
+
|
|
|
+ // 使用原子变量来保证线程安全
|
|
|
+ AtomicLong totalOriginalSize = new AtomicLong(0);
|
|
|
+ AtomicLong totalCompressedSize = new AtomicLong(0);
|
|
|
+ AtomicInteger successCount = new AtomicInteger(0);
|
|
|
+ AtomicInteger failCount = new AtomicInteger(0);
|
|
|
+ AtomicInteger skipCount = new AtomicInteger(0);
|
|
|
+ AtomicInteger processedCount = new AtomicInteger(0);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 分批处理文件
|
|
|
+ List<Future<?>> futures = new ArrayList<>();
|
|
|
+
|
|
|
+ for (int i = 0; i < imageFiles.size(); i += batchSize) {
|
|
|
+ int endIndex = Math.min(i + batchSize, imageFiles.size());
|
|
|
+ List<File> batch = imageFiles.subList(i, endIndex);
|
|
|
+
|
|
|
+ Future<?> future = executor.submit(() -> processBatch(batch,
|
|
|
+ totalOriginalSize, totalCompressedSize, successCount, failCount, skipCount, processedCount, targetSizeKB, targetSizeBytes, minSizeToCompress, enableDetailedLog));
|
|
|
+ futures.add(future);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 等待所有任务完成
|
|
|
+ for (Future<?> future : futures) {
|
|
|
+ try {
|
|
|
+ future.get();
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("❌ 批处理任务执行失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ } finally {
|
|
|
+ // 关闭线程池
|
|
|
+ executor.shutdown();
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
|
|
|
+ executor.shutdownNow();
|
|
|
+ }
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ executor.shutdownNow();
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 输出最终结果
|
|
|
+ CompressionResult result = new CompressionResult(
|
|
|
+ imageFiles.size(), successCount.get(), failCount.get(), skipCount.get(),
|
|
|
+ totalOriginalSize.get(), totalCompressedSize.get()
|
|
|
+ );
|
|
|
+
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("\n==========================================");
|
|
|
+ logger.info("🎉 压缩完成!");
|
|
|
+ logger.info("📊 统计信息:");
|
|
|
+ logger.info(" 总文件数: {}", result.getTotalFiles());
|
|
|
+ logger.info(" 跳过数量: {} (小于{}KB)", result.getSkipCount(), targetSizeKB);
|
|
|
+ logger.info(" 成功数量: {}", result.getSuccessCount());
|
|
|
+ logger.info(" 失败数量: {}", result.getFailCount());
|
|
|
+ logger.info(" 原始总大小: {}", formatFileSize(result.getTotalOriginalSize()));
|
|
|
+ logger.info(" 压缩后总大小: {}", formatFileSize(result.getTotalCompressedSize()));
|
|
|
+ logger.info(" 压缩比例: {}%", String.format("%.1f", result.getCompressionRatio()));
|
|
|
+ logger.info("==========================================");
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量处理图片文件(线程安全)
|
|
|
+ */
|
|
|
+ private static void processBatch(List<File> batch,
|
|
|
+ AtomicLong totalOriginalSize,
|
|
|
+ AtomicLong totalCompressedSize,
|
|
|
+ AtomicInteger successCount,
|
|
|
+ AtomicInteger failCount,
|
|
|
+ AtomicInteger skipCount,
|
|
|
+ AtomicInteger processedCount,
|
|
|
+ long targetSizeKB,
|
|
|
+ long targetSizeBytes,
|
|
|
+ long minSizeToCompress,
|
|
|
+ boolean enableDetailedLog) {
|
|
|
+
|
|
|
+ for (File imageFile : batch) {
|
|
|
+ try {
|
|
|
+ long originalSize = imageFile.length();
|
|
|
+ totalOriginalSize.addAndGet(originalSize);
|
|
|
+
|
|
|
+ // 检查文件是否太小,不需要压缩
|
|
|
+ if (originalSize < minSizeToCompress) {
|
|
|
+ skipCount.incrementAndGet();
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("⏭️ 跳过小文件: {} ({} KB) - 小于{}KB,无需压缩",
|
|
|
+ imageFile.getName(),
|
|
|
+ String.format("%.2f", originalSize / 1024.0),
|
|
|
+ targetSizeKB);
|
|
|
+ }
|
|
|
+ processedCount.incrementAndGet();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 压缩图片
|
|
|
+ File compressedFile = compressImage(imageFile, targetSizeKB, targetSizeBytes, enableDetailedLog);
|
|
|
+
|
|
|
+ if (compressedFile != null && compressedFile.exists()) {
|
|
|
+ long compressedSize = compressedFile.length();
|
|
|
+ totalCompressedSize.addAndGet(compressedSize);
|
|
|
+
|
|
|
+ // 检查压缩比例是否达到要求
|
|
|
+ if (compressedSize <= targetSizeBytes) {
|
|
|
+ // 覆盖原文件
|
|
|
+ FileUtils.copyFile(compressedFile, imageFile);
|
|
|
+ successCount.incrementAndGet();
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("✅ 压缩成功: {} ({} KB -> {} KB)",
|
|
|
+ imageFile.getName(),
|
|
|
+ String.format("%.2f", originalSize / 1024.0),
|
|
|
+ String.format("%.2f", compressedSize / 1024.0));
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 压缩效果不理想,删除临时文件
|
|
|
+ compressedFile.delete();
|
|
|
+ failCount.incrementAndGet();
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.warn("⚠️ 压缩效果不理想: {} (目标: {} KB, 实际: {} KB)",
|
|
|
+ imageFile.getName(),
|
|
|
+ targetSizeKB,
|
|
|
+ String.format("%.2f", compressedSize / 1024.0));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ failCount.incrementAndGet();
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.error("❌ 压缩失败: {}", imageFile.getName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ failCount.incrementAndGet();
|
|
|
+ logger.error("❌ 处理文件失败: {} - {}", imageFile.getName(), e.getMessage());
|
|
|
+ } finally {
|
|
|
+ processedCount.incrementAndGet();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 递归查找所有图片文件
|
|
|
+ */
|
|
|
+ private static List<File> findImageFiles(File directory, String[] supportedFormats) {
|
|
|
+ List<File> imageFiles = new ArrayList<>();
|
|
|
+
|
|
|
+ if (directory.isDirectory()) {
|
|
|
+ File[] files = directory.listFiles();
|
|
|
+ if (files != null) {
|
|
|
+ for (File file : files) {
|
|
|
+ if (file.isDirectory()) {
|
|
|
+ imageFiles.addAll(findImageFiles(file, supportedFormats));
|
|
|
+ } else if (isImageFile(file, supportedFormats)) {
|
|
|
+ imageFiles.add(file);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return imageFiles;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断文件是否为支持的图片格式
|
|
|
+ */
|
|
|
+ private static boolean isImageFile(File file, String[] supportedFormats) {
|
|
|
+ String extension = FilenameUtils.getExtension(file.getName()).toLowerCase();
|
|
|
+ for (String format : supportedFormats) {
|
|
|
+ if (format.equals(extension)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 压缩单个图片文件
|
|
|
+ */
|
|
|
+ private static File compressImage(File originalFile, long targetSizeKB, long targetSizeBytes, boolean enableDetailedLog) throws IOException {
|
|
|
+ String extension = FilenameUtils.getExtension(originalFile.getName()).toLowerCase();
|
|
|
+
|
|
|
+ // 创建临时文件
|
|
|
+ File tempFile = File.createTempFile("compressed_", "." + extension);
|
|
|
+ tempFile.deleteOnExit();
|
|
|
+
|
|
|
+ BufferedImage originalImage = ImageIO.read(originalFile);
|
|
|
+ if (originalImage == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取原始尺寸
|
|
|
+ int originalWidth = originalImage.getWidth();
|
|
|
+ int originalHeight = originalImage.getHeight();
|
|
|
+
|
|
|
+ // 渐进式压缩参数
|
|
|
+ double quality = 0.9;
|
|
|
+ double scale = 1.0;
|
|
|
+ int maxAttempts = 20; // 增加尝试次数
|
|
|
+ int attempt = 0;
|
|
|
+
|
|
|
+ while (attempt < maxAttempts) {
|
|
|
+ // 计算目标尺寸
|
|
|
+ int targetWidth = (int) (originalWidth * scale);
|
|
|
+ int targetHeight = (int) (originalHeight * scale);
|
|
|
+
|
|
|
+ // 确保最小尺寸(降低最小尺寸限制)
|
|
|
+ if (targetWidth < 50 || targetHeight < 50) {
|
|
|
+ targetWidth = Math.max(50, targetWidth);
|
|
|
+ targetHeight = Math.max(50, targetHeight);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用Thumbnailator进行压缩
|
|
|
+ Thumbnails.of(originalImage)
|
|
|
+ .size(targetWidth, targetHeight)
|
|
|
+ .outputQuality(quality)
|
|
|
+ .toFile(tempFile);
|
|
|
+
|
|
|
+ // 检查文件大小
|
|
|
+ long fileSize = tempFile.length();
|
|
|
+
|
|
|
+ // 如果文件大小已经达到目标,则停止压缩
|
|
|
+ if (fileSize <= targetSizeBytes) {
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("🎯 达到目标大小: {} ({} KB) - 尝试次数: {}",
|
|
|
+ originalFile.getName(), String.format("%.2f", fileSize / 1024.0), attempt + 1);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果文件仍然太大,继续压缩
|
|
|
+ attempt++;
|
|
|
+
|
|
|
+ // 更精细的压缩策略
|
|
|
+ if (attempt <= 5) {
|
|
|
+ // 前5次主要调整质量,步长更小
|
|
|
+ quality = Math.max(0.2, quality - 0.1);
|
|
|
+ } else if (attempt <= 10) {
|
|
|
+ // 中间5次同时调整质量和尺寸
|
|
|
+ quality = Math.max(0.1, quality - 0.05);
|
|
|
+ scale = Math.max(0.2, scale - 0.1);
|
|
|
+ } else {
|
|
|
+ // 最后10次主要调整尺寸,步长更小
|
|
|
+ scale = Math.max(0.1, scale - 0.05);
|
|
|
+ quality = Math.max(0.05, quality - 0.02);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (enableDetailedLog) {
|
|
|
+ logger.info("🔄 压缩尝试 {}: {} ({} KB) - 质量: {}, 尺寸: {}",
|
|
|
+ attempt, originalFile.getName(), String.format("%.2f", fileSize / 1024.0),
|
|
|
+ String.format("%.2f", quality), String.format("%.2f", scale));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 最终检查
|
|
|
+ long finalSize = tempFile.length();
|
|
|
+ if (finalSize > targetSizeBytes && enableDetailedLog) {
|
|
|
+ logger.warn("⚠️ 无法压缩到目标大小: {} (最终: {} KB, 目标: {} KB)",
|
|
|
+ originalFile.getName(), String.format("%.2f", finalSize / 1024.0), targetSizeKB);
|
|
|
+ }
|
|
|
+
|
|
|
+ return tempFile;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化文件大小显示
|
|
|
+ */
|
|
|
+ private static String formatFileSize(long size) {
|
|
|
+ if (size < 1024) {
|
|
|
+ return size + " B";
|
|
|
+ } else if (size < 1024 * 1024) {
|
|
|
+ return String.format("%.2f KB", size / 1024.0);
|
|
|
+ } else if (size < 1024 * 1024 * 1024) {
|
|
|
+ return String.format("%.2f MB", size / (1024.0 * 1024.0));
|
|
|
+ } else {
|
|
|
+ return String.format("%.2f GB", size / (1024.0 * 1024.0 * 1024.0));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|