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}'); } // Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse 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)); } // AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir) class _ViewportCropParams { final String sourcePath; final double offsetDx; final double offsetDy; final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain) final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran final double rotationDegrees; final int outputSize; final String outputPath; _ViewportCropParams({ required this.sourcePath, required this.offsetDx, required this.offsetDy, required this.displayPerSourcePx, required this.cropSizeDisplay, required this.rotationDegrees, required this.outputSize, required this.outputPath, }); } // AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation // (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute // zone qui déborde de l'image → la cible reste exactement là où l'utilisateur // l'a placée, même collée à un bord (aucun recentrage forcé). void _viewportCropIsolateEntry(_ViewportCropParams p) { final bytes = File(p.sourcePath).readAsBytesSync(); final img.Image? src = img.decodeImage(bytes); if (src == null) { throw Exception('Impossible de décoder l\'image: ${p.sourcePath}'); } // Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels. img.Image rotated = src; if (p.rotationDegrees != 0.0) { rotated = img.copyRotate( src, angle: p.rotationDegrees, interpolation: img.Interpolation.linear, ); } final double f = p.displayPerSourcePx; // Côté de la fenêtre de visée, exprimé en pixels source (rotation = même échelle) final int side = math.max(1, (p.cropSizeDisplay / f).round()); // Centre de la fenêtre de visée, en pixels de l'image (rotated) : // le centre de l'image est affiché à (centre_viewport + offset), la fenêtre // est centrée sur le viewport → décalage de -offset/f par rapport au centre. final double cropCenterX = rotated.width / 2 - p.offsetDx / f; final double cropCenterY = rotated.height / 2 - p.offsetDy / f; final int srcX = (cropCenterX - side / 2).round(); final int srcY = (cropCenterY - side / 2).round(); // Toile carrée noire opaque ; compositeImage rogne automatiquement les bords // hors-cadre, donc les débordements restent noirs (padding). final out = img.Image(width: side, height: side, numChannels: 3); img.compositeImage(out, rotated, dstX: -srcX, dstY: -srcY); img.Image result = out; if (side != p.outputSize) { result = img.copyResize( out, width: p.outputSize, height: p.outputSize, interpolation: img.Interpolation.linear, ); } File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85)); } class ImageCropService { final Uuid _uuid = const Uuid(); static const int maxOutputSize = 1024; /// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage. /// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et /// tout débordement hors de l'image est rempli en noir (jamais de recentrage). /// /// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran. /// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain). /// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran. Future cropViewport({ required String sourcePath, required double offsetDx, required double offsetDy, required double displayPerSourcePx, required double cropSizeDisplay, double rotationDegrees = 0.0, int outputSize = maxOutputSize, }) async { final tempDir = await getTemporaryDirectory(); final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg'; final params = _ViewportCropParams( sourcePath: sourcePath, offsetDx: offsetDx, offsetDy: offsetDy, displayPerSourcePx: displayPerSourcePx, cropSizeDisplay: cropSizeDisplay, rotationDegrees: rotationDegrees, outputSize: outputSize, outputPath: outputPath, ); // Traitement lourd dans un Isolate → thread UI fluide. await Isolate.run(() => _viewportCropIsolateEntry(params)); return outputPath; } 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; } }