196 lines
7.7 KiB
Dart
196 lines
7.7 KiB
Dart
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:path/path.dart' as path;
|
||
import 'package:image/image.dart' as img; // CR-005: 画像圧縮・リサイズ用
|
||
|
||
/// 画像圧縮サービス
|
||
/// Gemini APIのトークン消費を削減するため、画像を最適化します
|
||
class ImageCompressionService {
|
||
/// 最大画像サイズ(長辺): 1024px
|
||
/// Geminiは高解像度でなくても十分な認識精度があります
|
||
static const int maxDimension = 1024;
|
||
|
||
/// JPEG品質: 85% (品質と容量のバランス)
|
||
static const int jpegQuality = 85;
|
||
|
||
/// 画像を圧縮してGemini API用に最適化
|
||
///
|
||
/// [sourcePath] 元画像のパス
|
||
/// [targetPath] 圧縮後の保存先パス(nullの場合は自動生成)
|
||
///
|
||
/// 戻り値: 圧縮後の画像パス
|
||
static Future<String> compressForGemini(String sourcePath, {String? targetPath}) async {
|
||
try {
|
||
final File sourceFile = File(sourcePath);
|
||
if (!await sourceFile.exists()) {
|
||
throw Exception('Source image not found: $sourcePath');
|
||
}
|
||
|
||
// CR-005: 画像をデコード(image packageを使用)
|
||
final Uint8List imageBytes = await sourceFile.readAsBytes();
|
||
final img.Image? originalImage = img.decodeImage(imageBytes);
|
||
|
||
if (originalImage == null) {
|
||
debugPrint('Failed to decode image, returning original');
|
||
return sourcePath;
|
||
}
|
||
|
||
final int originalWidth = originalImage.width;
|
||
final int originalHeight = originalImage.height;
|
||
|
||
// リサイズが不要な場合は、圧縮のみ実施
|
||
if (originalWidth <= maxDimension && originalHeight <= maxDimension) {
|
||
debugPrint('Image dimensions OK: ${originalWidth}x$originalHeight, applying JPEG compression');
|
||
|
||
// 保存先パス決定
|
||
final String outputPath = targetPath ?? await _generateCompressedPath(sourcePath);
|
||
|
||
// JPEG圧縮のみ適用
|
||
final compressedBytes = img.encodeJpg(originalImage, quality: jpegQuality);
|
||
await File(outputPath).writeAsBytes(compressedBytes);
|
||
|
||
final originalSize = imageBytes.length;
|
||
final compressedSize = compressedBytes.length;
|
||
|
||
debugPrint('Compression result: ${(originalSize / 1024).toStringAsFixed(1)}KB -> ${(compressedSize / 1024).toStringAsFixed(1)}KB');
|
||
|
||
return outputPath;
|
||
}
|
||
|
||
// CR-005: リサイズが必要な場合
|
||
debugPrint('Resizing image: ${originalWidth}x$originalHeight -> max $maxDimension');
|
||
|
||
// アスペクト比を保ったままリサイズ
|
||
final img.Image resized = img.copyResize(
|
||
originalImage,
|
||
width: originalWidth > originalHeight ? maxDimension : null,
|
||
height: originalHeight > originalWidth ? maxDimension : null,
|
||
interpolation: img.Interpolation.linear, // 高品質な補間
|
||
);
|
||
|
||
// 保存先パス決定
|
||
final String outputPath = targetPath ?? await _generateCompressedPath(sourcePath);
|
||
|
||
// JPEG形式で保存
|
||
final compressedBytes = img.encodeJpg(resized, quality: jpegQuality);
|
||
await File(outputPath).writeAsBytes(compressedBytes);
|
||
|
||
final originalSize = imageBytes.length;
|
||
final compressedSize = compressedBytes.length;
|
||
|
||
debugPrint('Resize & Compression result: ${originalWidth}x$originalHeight (${(originalSize / 1024).toStringAsFixed(1)}KB) -> ${resized.width}x${resized.height} (${(compressedSize / 1024).toStringAsFixed(1)}KB)');
|
||
|
||
return outputPath;
|
||
|
||
} catch (e) {
|
||
debugPrint('Image compression error: $e');
|
||
// エラー時は元のパスを返す(フォールバック)
|
||
return sourcePath;
|
||
}
|
||
}
|
||
|
||
/// 圧縮画像の保存先パスを生成(一時ディレクトリ)
|
||
///
|
||
/// 🔒 一時ファイルは getTemporaryDirectory() に保存
|
||
static Future<String> _generateCompressedPath(String sourcePath) async {
|
||
final directory = await getTemporaryDirectory();
|
||
final fileName = path.basenameWithoutExtension(sourcePath);
|
||
final extension = path.extension(sourcePath);
|
||
return path.join(directory.path, '${fileName}_compressed$extension');
|
||
}
|
||
|
||
/// ファイルサイズを取得(デバッグ用)
|
||
static Future<int> getFileSize(String filePath) async {
|
||
final file = File(filePath);
|
||
return await file.length();
|
||
}
|
||
|
||
/// ファイルサイズを人間が読みやすい形式で取得
|
||
static Future<String> getFileSizeString(String filePath) async {
|
||
final size = await getFileSize(filePath);
|
||
if (size < 1024) {
|
||
return '$size B';
|
||
} else if (size < 1024 * 1024) {
|
||
return '${(size / 1024).toStringAsFixed(1)} KB';
|
||
} else {
|
||
return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||
}
|
||
}
|
||
|
||
/// ギャラリー保存用に画像を圧縮(高品質版)
|
||
///
|
||
/// [sourcePath] 元画像のパス
|
||
/// [targetPath] 圧縮後の保存先パス(nullの場合は自動生成)
|
||
/// [maxDimension] 最大画像サイズ(デフォルト: 2000px)
|
||
/// [quality] JPEG品質(デフォルト: 90%)
|
||
///
|
||
/// 戻り値: 圧縮後の画像パス
|
||
static Future<String> compressForGallery(
|
||
String sourcePath, {
|
||
String? targetPath,
|
||
int maxDimension = 2000,
|
||
int quality = 90,
|
||
}) async {
|
||
try {
|
||
final File sourceFile = File(sourcePath);
|
||
if (!await sourceFile.exists()) {
|
||
throw Exception('Source image not found: $sourcePath');
|
||
}
|
||
|
||
final Uint8List imageBytes = await sourceFile.readAsBytes();
|
||
final img.Image? originalImage = img.decodeImage(imageBytes);
|
||
|
||
if (originalImage == null) {
|
||
debugPrint('Failed to decode image, returning original');
|
||
return sourcePath;
|
||
}
|
||
|
||
final int originalWidth = originalImage.width;
|
||
final int originalHeight = originalImage.height;
|
||
|
||
// リサイズが不要な場合は、圧縮のみ実施
|
||
if (originalWidth <= maxDimension && originalHeight <= maxDimension) {
|
||
debugPrint('Gallery: Image dimensions OK: ${originalWidth}x$originalHeight, applying JPEG compression');
|
||
|
||
final String outputPath = targetPath ?? await _generateCompressedPath(sourcePath);
|
||
final compressedBytes = img.encodeJpg(originalImage, quality: quality);
|
||
await File(outputPath).writeAsBytes(compressedBytes);
|
||
|
||
final originalSize = imageBytes.length;
|
||
final compressedSize = compressedBytes.length;
|
||
|
||
debugPrint('Gallery compression: ${(originalSize / 1024).toStringAsFixed(1)}KB -> ${(compressedSize / 1024).toStringAsFixed(1)}KB');
|
||
|
||
return outputPath;
|
||
}
|
||
|
||
// リサイズが必要な場合
|
||
debugPrint('Gallery: Resizing image: ${originalWidth}x$originalHeight -> max $maxDimension');
|
||
|
||
final img.Image resized = img.copyResize(
|
||
originalImage,
|
||
width: originalWidth > originalHeight ? maxDimension : null,
|
||
height: originalHeight > originalWidth ? maxDimension : null,
|
||
interpolation: img.Interpolation.cubic, // ギャラリー用は最高品質
|
||
);
|
||
|
||
final String outputPath = targetPath ?? await _generateCompressedPath(sourcePath);
|
||
final compressedBytes = img.encodeJpg(resized, quality: quality);
|
||
await File(outputPath).writeAsBytes(compressedBytes);
|
||
|
||
final originalSize = imageBytes.length;
|
||
final compressedSize = compressedBytes.length;
|
||
|
||
debugPrint('Gallery resize & compression: ${originalWidth}x$originalHeight (${(originalSize / 1024).toStringAsFixed(1)}KB) -> ${resized.width}x${resized.height} (${(compressedSize / 1024).toStringAsFixed(1)}KB)');
|
||
|
||
return outputPath;
|
||
|
||
} catch (e) {
|
||
debugPrint('Gallery image compression error: $e');
|
||
return sourcePath;
|
||
}
|
||
}
|
||
}
|