202 lines
6.2 KiB
Dart
202 lines
6.2 KiB
Dart
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));
|
|
}
|
|
|
|
class ImageCropService {
|
|
final Uuid _uuid = const Uuid();
|
|
|
|
static const int maxOutputSize = 1024;
|
|
|
|
Future<String> 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<void> 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<String> 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;
|
|
}
|
|
} |