474 lines
13 KiB
Dart
474 lines
13 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||
import 'package:image/image.dart' as img;
|
||
import 'package:path/path.dart' as path;
|
||
import 'package:uuid/uuid.dart';
|
||
import '../constants/app_constants.dart';
|
||
import '../errors/app_error.dart';
|
||
import 'logger.dart';
|
||
|
||
/// 图片处理工具类
|
||
/// 提供图片压缩、格式转换、缩略图生成等功能
|
||
class ImageUtils {
|
||
// 私有构造函数,防止实例化
|
||
ImageUtils._();
|
||
|
||
static const Uuid _uuid = Uuid();
|
||
|
||
/// 生成缩略图
|
||
/// [imagePath] 原图路径
|
||
/// [targetPath] 缩略图保存路径
|
||
/// [maxSize] 缩略图最大尺寸(长边)
|
||
/// [quality] 压缩质量(0-100)
|
||
static Future<String> generateThumbnail({
|
||
required String imagePath,
|
||
required String targetPath,
|
||
int maxSize = AppConstants.maxThumbnailSize,
|
||
int quality = AppConstants.thumbnailQuality,
|
||
}) async {
|
||
try {
|
||
Logger.debug('开始生成缩略图: $imagePath');
|
||
|
||
// 检查原文件是否存在
|
||
final originalFile = File(imagePath);
|
||
if (!await originalFile.exists()) {
|
||
throw ImageProcessingError(
|
||
message: '原图文件不存在: $imagePath',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
// 获取文件信息
|
||
final fileSize = await originalFile.length();
|
||
Logger.debug('原图文件大小: ${fileSize / 1024 / 1024}MB');
|
||
|
||
// 检查文件大小限制
|
||
if (fileSize > AppConstants.maxImageSize) {
|
||
throw ImageProcessingError(
|
||
message: '图片文件过大,最大支持30MB',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
// 根据文件类型选择处理方式
|
||
final fileExtension = path.extension(imagePath).toLowerCase();
|
||
|
||
if (fileExtension == '.gif') {
|
||
return await _generateGifThumbnail(
|
||
imagePath: imagePath,
|
||
targetPath: targetPath,
|
||
maxSize: maxSize,
|
||
quality: quality,
|
||
);
|
||
} else {
|
||
return await _generateStaticImageThumbnail(
|
||
imagePath: imagePath,
|
||
targetPath: targetPath,
|
||
maxSize: maxSize,
|
||
quality: quality,
|
||
);
|
||
}
|
||
} catch (error, stackTrace) {
|
||
Logger.error('生成缩略图失败', error: error, stackTrace: stackTrace);
|
||
|
||
if (error is ImageProcessingError) {
|
||
rethrow;
|
||
}
|
||
|
||
throw ImageProcessingError(
|
||
message: '生成缩略图失败: ${error.toString()}',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
stackTrace: stackTrace,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 生成静态图片缩略图
|
||
static Future<String> _generateStaticImageThumbnail({
|
||
required String imagePath,
|
||
required String targetPath,
|
||
required int maxSize,
|
||
required int quality,
|
||
}) async {
|
||
try {
|
||
// 使用 flutter_image_compress 进行压缩
|
||
final result = await FlutterImageCompress.compressAndGetFile(
|
||
imagePath,
|
||
targetPath,
|
||
quality: quality,
|
||
minWidth: maxSize,
|
||
minHeight: maxSize,
|
||
format: CompressFormat.webp,
|
||
keepExif: false,
|
||
);
|
||
|
||
if (result == null) {
|
||
throw ImageProcessingError(
|
||
message: '图片压缩失败,返回结果为空',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
Logger.debug('静态图片缩略图生成成功: ${result.path}');
|
||
return result.path;
|
||
} catch (error, stackTrace) {
|
||
Logger.error('静态图片压缩失败', error: error, stackTrace: stackTrace);
|
||
|
||
// 如果 flutter_image_compress 失败,使用 image 库作为备选方案
|
||
return await _generateThumbnailWithImageLib(
|
||
imagePath: imagePath,
|
||
targetPath: targetPath,
|
||
maxSize: maxSize,
|
||
quality: quality,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 使用 image 库生成缩略图(备选方案)
|
||
static Future<String> _generateThumbnailWithImageLib({
|
||
required String imagePath,
|
||
required String targetPath,
|
||
required int maxSize,
|
||
required int quality,
|
||
}) async {
|
||
try {
|
||
// 读取原图
|
||
final originalImage = img.decodeImage(await File(imagePath).readAsBytes());
|
||
if (originalImage == null) {
|
||
throw ImageProcessingError(
|
||
message: '无法解码图片文件',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
// 计算缩放比例
|
||
final originalWidth = originalImage.width;
|
||
final originalHeight = originalImage.height;
|
||
double scale = 1.0;
|
||
|
||
if (originalWidth > originalHeight) {
|
||
// 横图,以宽度为基准
|
||
if (originalWidth > maxSize) {
|
||
scale = maxSize / originalWidth;
|
||
}
|
||
} else {
|
||
// 竖图或方图,以高度为基准
|
||
if (originalHeight > maxSize) {
|
||
scale = maxSize / originalHeight;
|
||
}
|
||
}
|
||
|
||
final targetWidth = (originalWidth * scale).round();
|
||
final targetHeight = (originalHeight * scale).round();
|
||
|
||
Logger.debug('图片缩放: ${originalWidth}x$originalHeight -> ${targetWidth}x$targetHeight');
|
||
|
||
// 缩放图片
|
||
final resizedImage = img.copyResize(
|
||
originalImage,
|
||
width: targetWidth,
|
||
height: targetHeight,
|
||
);
|
||
|
||
// 保存为JPEG格式(简化处理)
|
||
final jpegData = img.encodeJpg(resizedImage, quality: quality);
|
||
final thumbnailFile = File(targetPath);
|
||
await thumbnailFile.writeAsBytes(jpegData);
|
||
|
||
Logger.debug('使用 image 库生成缩略图成功: $targetPath');
|
||
return targetPath;
|
||
} catch (error, stackTrace) {
|
||
throw ImageProcessingError(
|
||
message: '图片库处理失败: ${error.toString()}',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
stackTrace: stackTrace,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 生成GIF缩略图
|
||
static Future<String> _generateGifThumbnail({
|
||
required String imagePath,
|
||
required String targetPath,
|
||
required int maxSize,
|
||
required int quality,
|
||
}) async {
|
||
try {
|
||
Logger.debug('处理GIF缩略图: $imagePath');
|
||
|
||
// 读取GIF文件
|
||
final gifFile = File(imagePath);
|
||
final gifBytes = await gifFile.readAsBytes();
|
||
|
||
// 解码GIF
|
||
final gifImage = img.decodeGif(gifBytes);
|
||
if (gifImage == null) {
|
||
throw ImageProcessingError(
|
||
message: '无法解码GIF文件',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
// 对于大GIF文件,只提取第一帧作为缩略图
|
||
if (gifImage.length > 1) {
|
||
Logger.debug('GIF文件包含 ${gifImage.length} 帧,提取第一帧作为缩略图');
|
||
}
|
||
|
||
// 获取第一帧
|
||
final firstFrame = gifImage.frames.isNotEmpty ? gifImage.frames.first : null;
|
||
|
||
if (firstFrame == null) {
|
||
throw ImageProcessingError(
|
||
message: '无法获取GIF的第一帧',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
// 计算缩放比例
|
||
final originalWidth = firstFrame.width;
|
||
final originalHeight = firstFrame.height;
|
||
double scale = 1.0;
|
||
|
||
if (originalWidth > originalHeight) {
|
||
if (originalWidth > maxSize) {
|
||
scale = maxSize / originalWidth;
|
||
}
|
||
} else {
|
||
if (originalHeight > maxSize) {
|
||
scale = maxSize / originalHeight;
|
||
}
|
||
}
|
||
|
||
final targetWidth = (originalWidth * scale).round();
|
||
final targetHeight = (originalHeight * scale).round();
|
||
|
||
// 缩放第一帧
|
||
final resizedFrame = img.copyResize(
|
||
firstFrame,
|
||
width: targetWidth,
|
||
height: targetHeight,
|
||
);
|
||
|
||
// 保存为JPEG格式
|
||
final jpegData = img.encodeJpg(resizedFrame, quality: quality);
|
||
final thumbnailFile = File(targetPath);
|
||
await thumbnailFile.writeAsBytes(jpegData);
|
||
|
||
Logger.debug('GIF缩略图生成成功: $targetPath');
|
||
return targetPath;
|
||
} catch (error, stackTrace) {
|
||
throw ImageProcessingError(
|
||
message: 'GIF缩略图生成失败: ${error.toString()}',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
stackTrace: stackTrace,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 生成图片的唯一文件名
|
||
/// [extension] 文件扩展名(包含点号)
|
||
static String generateUniqueFileName(String extension) {
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final uuid = _uuid.v4().substring(0, 8);
|
||
return '${timestamp}_$uuid$extension';
|
||
}
|
||
|
||
/// 根据MIME类型获取文件扩展名
|
||
static String getExtensionFromMimeType(String mimeType) {
|
||
final mimeTypeMap = {
|
||
'image/jpeg': '.jpg',
|
||
'image/jpg': '.jpg',
|
||
'image/png': '.png',
|
||
'image/gif': '.gif',
|
||
'image/webp': '.webp',
|
||
'image/heic': '.heic',
|
||
'image/heif': '.heif',
|
||
};
|
||
|
||
return mimeTypeMap[mimeType.toLowerCase()] ?? '.jpg';
|
||
}
|
||
|
||
/// 获取图片的基本信息
|
||
static Future<Map<String, dynamic>> getImageInfo(String imagePath) async {
|
||
try {
|
||
final file = File(imagePath);
|
||
if (!await file.exists()) {
|
||
throw ImageProcessingError(
|
||
message: '图片文件不存在: $imagePath',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
final bytes = await file.readAsBytes();
|
||
final image = img.decodeImage(bytes);
|
||
|
||
if (image == null) {
|
||
throw ImageProcessingError(
|
||
message: '无法解码图片文件',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
);
|
||
}
|
||
|
||
return {
|
||
'width': image.width,
|
||
'height': image.height,
|
||
'size': bytes.length,
|
||
'format': path.extension(imagePath).toLowerCase(),
|
||
'mimeType': _getMimeType(imagePath),
|
||
};
|
||
} catch (error, stackTrace) {
|
||
Logger.error('获取图片信息失败', error: error, stackTrace: stackTrace);
|
||
|
||
if (error is ImageProcessingError) {
|
||
rethrow;
|
||
}
|
||
|
||
throw ImageProcessingError(
|
||
message: '获取图片信息失败: ${error.toString()}',
|
||
code: 'IMAGE_PROCESSING_ERROR',
|
||
stackTrace: stackTrace,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 根据文件路径获取MIME类型
|
||
static String _getMimeType(String filePath) {
|
||
final extension = path.extension(filePath).toLowerCase();
|
||
final mimeTypeMap = {
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.png': 'image/png',
|
||
'.gif': 'image/gif',
|
||
'.webp': 'image/webp',
|
||
'.heic': 'image/heic',
|
||
'.heif': 'image/heif',
|
||
};
|
||
|
||
return mimeTypeMap[extension] ?? 'image/jpeg';
|
||
}
|
||
|
||
/// 检查图片文件是否有效
|
||
static Future<bool> isValidImage(String imagePath) async {
|
||
try {
|
||
final file = File(imagePath);
|
||
if (!await file.exists()) {
|
||
return false;
|
||
}
|
||
|
||
final fileSize = await file.length();
|
||
if (fileSize == 0 || fileSize > AppConstants.maxImageSize) {
|
||
return false;
|
||
}
|
||
|
||
final extension = path.extension(imagePath).toLowerCase();
|
||
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif'];
|
||
|
||
if (!validExtensions.contains(extension)) {
|
||
return false;
|
||
}
|
||
|
||
// 尝试解码图片
|
||
final bytes = await file.readAsBytes();
|
||
final image = img.decodeImage(bytes);
|
||
|
||
return image != null;
|
||
} catch (error) {
|
||
Logger.error('图片验证失败', error: error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 计算图片的宽高比
|
||
static double calculateAspectRatio(int width, int height) {
|
||
if (width <= 0 || height <= 0) {
|
||
return 1.0;
|
||
}
|
||
return width / height;
|
||
}
|
||
|
||
/// 根据目标宽度计算等比例高度
|
||
static int calculateHeightForWidth(int originalWidth, int originalHeight, int targetWidth) {
|
||
if (originalWidth <= 0) {
|
||
return originalHeight;
|
||
}
|
||
return (originalHeight * targetWidth / originalWidth).round();
|
||
}
|
||
|
||
/// 根据目标高度计算等比例宽度
|
||
static int calculateWidthForHeight(int originalWidth, int originalHeight, int targetHeight) {
|
||
if (originalHeight <= 0) {
|
||
return originalWidth;
|
||
}
|
||
return (originalWidth * targetHeight / originalHeight).round();
|
||
}
|
||
}
|
||
|
||
/// 图片处理结果
|
||
class ImageProcessingResult {
|
||
final String originalPath;
|
||
final String thumbnailPath;
|
||
final Map<String, dynamic> metadata;
|
||
final bool success;
|
||
final String? errorMessage;
|
||
|
||
ImageProcessingResult({
|
||
required this.originalPath,
|
||
required this.thumbnailPath,
|
||
required this.metadata,
|
||
this.success = true,
|
||
this.errorMessage,
|
||
});
|
||
|
||
factory ImageProcessingResult.error(String originalPath, String errorMessage) {
|
||
return ImageProcessingResult(
|
||
originalPath: originalPath,
|
||
thumbnailPath: '',
|
||
metadata: {},
|
||
success: false,
|
||
errorMessage: errorMessage,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 支持的图片格式
|
||
class SupportedImageFormats {
|
||
static const List<String> formats = [
|
||
'jpg',
|
||
'jpeg',
|
||
'png',
|
||
'gif',
|
||
'webp',
|
||
'heic',
|
||
'heif',
|
||
];
|
||
|
||
static const List<String> extensions = [
|
||
'.jpg',
|
||
'.jpeg',
|
||
'.png',
|
||
'.gif',
|
||
'.webp',
|
||
'.heic',
|
||
'.heif',
|
||
];
|
||
|
||
/// 检查文件扩展名是否支持
|
||
static bool isSupported(String filePath) {
|
||
final extension = path.extension(filePath).toLowerCase();
|
||
return extensions.contains(extension);
|
||
}
|
||
|
||
/// 检查MIME类型是否支持
|
||
static bool isMimeTypeSupported(String mimeType) {
|
||
final supportedMimeTypes = [
|
||
'image/jpeg',
|
||
'image/jpg',
|
||
'image/png',
|
||
'image/gif',
|
||
'image/webp',
|
||
'image/heic',
|
||
'image/heif',
|
||
];
|
||
return supportedMimeTypes.contains(mimeType.toLowerCase());
|
||
}
|
||
} |