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 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 _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 getFileSize(String filePath) async { final file = File(filePath); return await file.length(); } /// ファイルサイズを人間が読みやすい形式で取得 static Future 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 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; } } }