From 608231f71cdd342642a6c73d824e0e4a522fcee1 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 15:52:14 +0200 Subject: [PATCH] =?UTF-8?q?C4=20:=20compression=20des=20images=20=C3=A0=20?= =?UTF-8?q?l'upload,=202560=20px=20/=20JPEG=20q82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flutter build web vert. Un seul helper, ImageCompressor, appelé par les DEUX chemins d'upload de resources_screen — c'est le motif qui a fait diverger Create et Upload côté serveur deux fois de suite : sur StoragePath en C1, sur le pré-vol de quota en C3. La compression se fait AVANT resourceCreate, pas avant l'upload. Le contrôle de quota livré en C3 porte sur sizeBytes à la création : le faire après aurait fait décider le serveur sur la taille du fichier d'origine, et compté au quota une image qui n'existe pas. Un quota qui compte 12x trop se remplit 12x trop vite. Deux écarts assumés par rapport à l'énoncé « 2560 px / JPEG q82 » : - Un PNG à canal alpha reste un PNG. Le passer en JPEG remplirait la transparence de noir, ce qui abîmerait logos et filigranes. Le redimensionnement s'applique dans les deux cas ; seul l'encodage diffère, et le type MIME suit — sinon Firebase servirait un JPEG étiqueté PNG. - Si le ré-encodage produit un fichier plus gros que l'original — ce qui arrive sur une petite image déjà bien encodée — l'original est conservé. Un échec de décodage renvoie aussi l'original plutôt que de bloquer l'upload. Défaut préexistant corrigé au passage : le second chemin d'upload ne renseignait sizeBytes ni avant ni après. Toutes les ressources créées par ce chemin comptent donc 0 octet au quota sur l'existant — même famille que ce que C1 et C3 ont trouvé côté serveur, et un argument de plus pour le backfill C2. image 4.2.0 déclarée explicitement : elle était déjà présente en transitif, dépendre d'un hasard de résolution pour une fonction utilisateur n'est pas tenable. Co-Authored-By: Claude Opus 5 --- lib/Helpers/ImageCompressor.dart | 76 +++++++++++++++++++++ lib/Screens/Resources/resources_screen.dart | 26 +++++-- pubspec.lock | 2 +- pubspec.yaml | 1 + 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 lib/Helpers/ImageCompressor.dart diff --git a/lib/Helpers/ImageCompressor.dart b/lib/Helpers/ImageCompressor.dart new file mode 100644 index 0000000..4a9cb7a --- /dev/null +++ b/lib/Helpers/ImageCompressor.dart @@ -0,0 +1,76 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +/// Compression des images à l'upload. +/// +/// Un seul point d'entrée, appelé par les **deux** chemins d'upload de +/// `resources_screen`. Côté serveur, ces deux chemins avaient divergé sur +/// `StoragePath` puis sur le contrôle de quota (C1 et C3) : la même erreur +/// coûterait ici des images compressées d'un côté et pas de l'autre. +class ImageCompressor { + /// Côté le plus long. Au-delà, une photo n'apporte rien à l'écran d'un + /// visiteur et coûte du stockage, du transfert et du temps de visite hors ligne. + static const int maxDimension = 2560; + + static const int jpegQuality = 82; + + static const List _compressibleExtensions = ['jpg', 'jpeg', 'png']; + + static bool handles(String? extension) => + extension != null && _compressibleExtensions.contains(extension.toLowerCase()); + + /// Renvoie les octets à téléverser et le type MIME correspondant. + /// + /// L'original est renvoyé tel quel si le format n'est pas gérable, si le + /// décodage échoue, ou si la compression n'a rien gagné — une image déjà + /// petite et bien encodée peut grossir en repassant par un ré-encodage. + static CompressedImage compress(Uint8List bytes, String? extension, String fallbackMimeType) { + if (!handles(extension)) { + return CompressedImage(bytes, fallbackMimeType, false); + } + + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) { + return CompressedImage(bytes, fallbackMimeType, false); + } + + final resized = (decoded.width > maxDimension || decoded.height > maxDimension) + ? img.copyResize( + decoded, + width: decoded.width >= decoded.height ? maxDimension : null, + height: decoded.height > decoded.width ? maxDimension : null, + interpolation: img.Interpolation.average, + ) + : decoded; + + // Un PNG à canal alpha reste un PNG : le passer en JPEG remplirait la + // transparence de noir, ce qui abîmerait les logos et les filigranes. + final keepsAlpha = decoded.hasAlpha; + final encoded = keepsAlpha + ? Uint8List.fromList(img.encodePng(resized)) + : Uint8List.fromList(img.encodeJpg(resized, quality: jpegQuality)); + + if (encoded.length >= bytes.length) { + return CompressedImage(bytes, fallbackMimeType, false); + } + + return CompressedImage(encoded, keepsAlpha ? 'image/png' : 'image/jpeg', true); + } catch (e) { + print("ERROR ImageCompressor.compress"); + print(e); + return CompressedImage(bytes, fallbackMimeType, false); + } + } +} + +class CompressedImage { + final Uint8List bytes; + final String mimeType; + final bool wasCompressed; + + CompressedImage(this.bytes, this.mimeType, this.wasCompressed); + + int get sizeBytes => bytes.length; +} diff --git a/lib/Screens/Resources/resources_screen.dart b/lib/Screens/Resources/resources_screen.dart index 3d3e8cc..d4192ac 100644 --- a/lib/Screens/Resources/resources_screen.dart +++ b/lib/Screens/Resources/resources_screen.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:manager_app/Components/common_loader.dart'; +import 'package:manager_app/Helpers/ImageCompressor.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; @@ -207,7 +208,11 @@ Future?> create(ResourceDTO resourceDTO, List? files, L print("Trying to create resource"); // ADD LOADING resourceDTO.dateCreation = DateTime.now(); - resourceDTO.sizeBytes = platformFile.size; + // Compresser AVANT resourceCreate : le contrôle de quota du serveur (C3) + // se fait sur sizeBytes à la création, il doit voir la taille réellement + // téléversée et non celle du fichier d'origine. + final compressed = ImageCompressor.compress(platformFile.bytes!, platformFile.extension, mimeType); + resourceDTO.sizeBytes = compressed.sizeBytes; ResourceDTO? newResource = await managerAppContext.clientAPI!.resourceApi!.resourceCreate(resourceDTO); print("created resource"); print(newResource); @@ -216,15 +221,16 @@ Future?> create(ResourceDTO resourceDTO, List? files, L Reference ref = storage.ref().child('pictures/${appContext.getContext().instanceId}/${Path.basename(newResource.id!.toString())}'); var metadata = SettableMetadata( - contentType: mimeType, // ou le type MIME approprié pour votre fichier + // Le MIME suit la compression : un PNG sans alpha ressort en JPEG. + contentType: compressed.mimeType, ); - UploadTask uploadTask = ref.putData(platformFile.bytes!, metadata); + UploadTask uploadTask = ref.putData(compressed.bytes, metadata); uploadTask.then((res) { res.ref.getDownloadURL().then((urlRessource) { showNotification(Colors.green, kWhite, AppLocalizations.of(context)!.resourceCreatedSuccess, context, null); newResource.url = urlRessource; - newResource.sizeBytes = platformFile.size; + newResource.sizeBytes = compressed.sizeBytes; (appContext.getContext() as ManagerAppContext).clientAPI!.resourceApi!.resourceUpdate(newResource); createdResources.add(newResource); index++; @@ -303,11 +309,21 @@ Future?> create(ResourceDTO resourceDTO, List? files, L FirebaseStorage storage = FirebaseStorage.instance; Reference ref = storage.ref().child('pictures/${appContext.getContext().instanceId}/${Path.basename(newResource.id!.toString())}'); - UploadTask uploadTask = ref.putData(platformFile.bytes!); + final compressed = ImageCompressor.compress( + platformFile.bytes!, + platformFile.extension, + 'application/octet-stream', + ); + + UploadTask uploadTask = ref.putData( + compressed.bytes, + SettableMetadata(contentType: compressed.mimeType), + ); uploadTask.then((res) { res.ref.getDownloadURL().then((urlImage) { showNotification(Colors.green, kWhite, 'La ressource a été créée avec succès', context, null); newResource.url = urlImage; + newResource.sizeBytes = compressed.sizeBytes; (appContext.getContext() as ManagerAppContext).clientAPI!.resourceApi!.resourceUpdate(newResource); return newResource; }); diff --git a/pubspec.lock b/pubspec.lock index e2d5c5d..9c5446b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -822,7 +822,7 @@ packages: source: hosted version: "1.0.1" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" diff --git a/pubspec.yaml b/pubspec.yaml index 103d50d..0d4c32d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: collection: any #filepicker_windows: ^2.0.0 file_picker: ^6.1.1 + image: ^4.2.0 # compression des images à l'upload (C4) — était déjà là en transitif flare_flutter: ^3.0.1 flutter_svg: ^2.2.1 #dart_vlc: ^0.0.6