import 'dart:isolate'; // AJOUT import 'dart:io'; import 'dart:math' as math; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; class CropRect { final double x; final double y; final double width; final double height; const CropRect({ required this.x, required this.y, required this.width, required this.height, }); @override String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)'; } // AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable) class _CropParams { final String sourcePath; final double cropX; final double cropY; final double cropWidth; final double cropHeight; final double rotationDegrees; final int outputSize; final String outputPath; _CropParams({ required this.sourcePath, required this.cropX, required this.cropY, required this.cropWidth, required this.cropHeight, required this.rotationDegrees, required this.outputSize, required this.outputPath, }); } // AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static) void _cropIsolateEntry(_CropParams params) { final file = File(params.sourcePath); final bytes = file.readAsBytesSync(); img.Image? originalImage = img.decodeImage(bytes); if (originalImage == null) { throw Exception('Impossible de décoder l\'image: ${params.sourcePath}'); } // ── OPTIMISATION MAJEURE : pré-réduction avant rotation ─────────────────── // La rotation (copyRotate) est l'opération la plus lourde du package `image` // et son coût est proportionnel au nombre de pixels. Comme la sortie finale // ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image // de plusieurs dizaines de mégapixels. // // On réduit donc l'image source à une "taille de travail" juste suffisante // AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une // marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte // visible. Les coordonnées de crop étant relatives (0..1), elles restent // valables quelle que soit l'échelle. final int workMax = (params.outputSize / 0.7).round(); final int longestSide = math.max(originalImage.width, originalImage.height); if (longestSide > workMax) { if (originalImage.width >= originalImage.height) { originalImage = img.copyResize( originalImage, width: workMax, interpolation: img.Interpolation.linear, ); } else { originalImage = img.copyResize( originalImage, height: workMax, interpolation: img.Interpolation.linear, ); } } // Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse // (désormais appliquée sur une image déjà réduite → beaucoup plus rapide) if (params.rotationDegrees != 0.0) { originalImage = img.copyRotate( originalImage, angle: params.rotationDegrees, interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse ); } // Recadrage final srcX = (params.cropX * originalImage.width).round(); final srcY = (params.cropY * originalImage.height).round(); final srcWidth = (params.cropWidth * originalImage.width).round(); final srcHeight = (params.cropHeight * originalImage.height).round(); final clampedX = srcX.clamp(0, originalImage.width - 1); final clampedY = srcY.clamp(0, originalImage.height - 1); final clampedWidth = math.min(srcWidth, originalImage.width - clampedX); final clampedHeight = math.min(srcHeight, originalImage.height - clampedY); img.Image cropped = img.copyCrop( originalImage, x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight, ); // Redimensionnement — LINEAR au lieu de CUBIC if (cropped.width != params.outputSize || cropped.height != params.outputSize) { cropped = img.copyResize( cropped, width: params.outputSize, height: params.outputSize, interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse ); } // Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable) final outputFile = File(params.outputPath); outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85)); } class ImageCropService { final Uuid _uuid = const Uuid(); static const int maxOutputSize = 1024; Future cropToSquare( String sourcePath, CropRect cropRect, { double rotationDegrees = 0.0, int outputSize = maxOutputSize, }) async { final tempDir = await getTemporaryDirectory(); final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg'; final params = _CropParams( sourcePath: sourcePath, cropX: cropRect.x, cropY: cropRect.y, cropWidth: cropRect.width, cropHeight: cropRect.height, rotationDegrees: rotationDegrees, outputSize: outputSize, outputPath: outputPath, ); // OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé // → le thread UI reste fluide pendant le calcul await Isolate.run(() => _cropIsolateEntry(params)); return outputPath; } /// Calcule la zone de recadrage carrée maximale centrée sur l'image CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) { final aspectRatio = imageWidth / imageHeight; if (aspectRatio > 1) { final squareWidth = imageHeight / imageWidth; final x = (1 - squareWidth) / 2; return CropRect(x: x, y: 0, width: squareWidth, height: 1); } else if (aspectRatio < 1) { final squareHeight = imageWidth / imageHeight; final y = (1 - squareHeight) / 2; return CropRect(x: 0, y: y, width: 1, height: squareHeight); } else { return const CropRect(x: 0, y: 0, width: 1, height: 1); } } /// Nettoie les fichiers temporaires de crop anciens Future cleanupOldCrops({Duration maxAge = const Duration(hours: 24)}) async { try { final tempDir = await getTemporaryDirectory(); final dir = Directory(tempDir.path); final now = DateTime.now(); await for (final entity in dir.list()) { if (entity is File && entity.path.contains('cropped_')) { final stat = await entity.stat(); final age = now.difference(stat.modified); if (age > maxAge) { await entity.delete(); } } } } catch (e) { // Ignorer les erreurs } } /// Coupe automatiquement une photo brute de l'appareil photo en carré /// calé sur la position haute du viseur de l'écran. Future cropRawCameraToSquare(String sourcePath) async { final file = File(sourcePath); final bytes = await file.readAsBytes(); final originalImage = img.decodeImage(bytes); if (originalImage == null) return sourcePath; final int imgW = originalImage.width; final int imgH = originalImage.height; final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round(); final int x = ((imgW - cropSizePixels) / 2).round(); final int y = ((imgH - cropSizePixels) / 2).round(); final squareImage = img.copyCrop( originalImage, x: x.clamp(0, imgW - 1), y: y.clamp(0, imgH - 1), width: cropSizePixels, height: cropSizePixels, ); final tempDir = await getTemporaryDirectory(); final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg'; await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85)); return outputPath; } }